[go_router] Adds `void replace()` and `replaceNamed` to `GoRouterDelegate`, `GoRouter` and `GoRouterHelper` (#2306)

* :sparkles: Add replace and replaceNamed

* :white_check_mark: Write a test for replace

* :arrow_up: :memo: Update version number and changelog

* :memo: Improve the documentation

* :art: Add missing extra line in changelog

* :white_check_mark: Add a test for replaceNamed
diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md
index dcaac3c..a2fc66f 100644
--- a/packages/go_router/CHANGELOG.md
+++ b/packages/go_router/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 4.2.0
+
+- Adds `void replace()` and `replaceNamed` to `GoRouterDelegate`, `GoRouter` and `GoRouterHelper`.
+
 ## 4.1.1
 
 - Fixes a bug where calling namedLocation does not support case-insensitive way.
diff --git a/packages/go_router/lib/go_router.dart b/packages/go_router/lib/go_router.dart
index 56bd957..a4fa36a 100644
--- a/packages/go_router/lib/go_router.dart
+++ b/packages/go_router/lib/go_router.dart
@@ -70,6 +70,35 @@
         extra: extra,
       );
 
+  /// Replaces the top-most page of the page stack with the given URL location
+  /// w/ optional query parameters, e.g. `/family/f2/person/p1?color=blue`.
+  ///
+  /// See also:
+  /// * [go] which navigates to the location.
+  /// * [push] which pushes the location onto the page stack.
+  void replace(String location, {Object? extra}) =>
+      GoRouter.of(this).replace(location, extra: extra);
+
+  /// Replaces the top-most page of the page stack with the named route w/
+  /// optional parameters, e.g. `name='person', params={'fid': 'f2', 'pid':
+  /// 'p1'}`.
+  ///
+  /// See also:
+  /// * [goNamed] which navigates a named route.
+  /// * [pushNamed] which pushes a named route onto the page stack.
+  void replaceNamed(
+    String name, {
+    Map<String, String> params = const <String, String>{},
+    Map<String, String> queryParams = const <String, String>{},
+    Object? extra,
+  }) =>
+      GoRouter.of(this).replaceNamed(
+        name,
+        params: params,
+        queryParams: queryParams,
+        extra: extra,
+      );
+
   /// Returns `true` if there is more than 1 page on the stack.
   bool canPop() => GoRouter.of(this).canPop();
 
diff --git a/packages/go_router/lib/src/go_router.dart b/packages/go_router/lib/src/go_router.dart
index 17a7af1..902c10f 100644
--- a/packages/go_router/lib/src/go_router.dart
+++ b/packages/go_router/lib/src/go_router.dart
@@ -160,6 +160,41 @@
         extra: extra,
       );
 
+  /// Replaces the top-most page of the page stack with the given URL location
+  /// w/ optional query parameters, e.g. `/family/f2/person/p1?color=blue`.
+  ///
+  /// See also:
+  /// * [go] which navigates to the location.
+  /// * [push] which pushes the location onto the page stack.
+  void replace(String location, {Object? extra}) {
+    routeInformationParser
+        .parseRouteInformation(
+      DebugGoRouteInformation(location: location, state: extra),
+    )
+        .then<void>((List<GoRouteMatch> matches) {
+      routerDelegate.replace(matches.last);
+    });
+  }
+
+  /// Replaces the top-most page of the page stack with the named route w/
+  /// optional parameters, e.g. `name='person', params={'fid': 'f2', 'pid':
+  /// 'p1'}`.
+  ///
+  /// See also:
+  /// * [goNamed] which navigates a named route.
+  /// * [pushNamed] which pushes a named route onto the page stack.
+  void replaceNamed(
+    String name, {
+    Map<String, String> params = const <String, String>{},
+    Map<String, String> queryParams = const <String, String>{},
+    Object? extra,
+  }) {
+    replace(
+      namedLocation(name, params: params, queryParams: queryParams),
+      extra: extra,
+    );
+  }
+
   /// Returns `true` if there is more than 1 page on the stack.
   bool canPop() => routerDelegate.canPop();
 
diff --git a/packages/go_router/lib/src/go_router_delegate.dart b/packages/go_router/lib/src/go_router_delegate.dart
index 3ea7e62..25830de 100644
--- a/packages/go_router/lib/src/go_router_delegate.dart
+++ b/packages/go_router/lib/src/go_router_delegate.dart
@@ -65,6 +65,15 @@
     notifyListeners();
   }
 
+  /// Replaces the top-most page of the page stack with the given one.
+  ///
+  /// See also:
+  /// * [push] which pushes the given location onto the page stack.
+  void replace(GoRouteMatch match) {
+    _matches.last = match;
+    notifyListeners();
+  }
+
   /// Returns `true` if there is more than 1 page on the stack.
   bool canPop() {
     return _matches.length > 1;
diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml
index 422a54c..ec58f36 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: 4.1.1
+version: 4.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
 
diff --git a/packages/go_router/test/go_router_delegate_test.dart b/packages/go_router/test/go_router_delegate_test.dart
index 73592a2..2716e18 100644
--- a/packages/go_router/test/go_router_delegate_test.dart
+++ b/packages/go_router/test/go_router_delegate_test.dart
@@ -78,6 +78,114 @@
     );
   });
 
+  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(
+            routeInformationProvider: goRouter.routeInformationProvider,
+            routeInformationParser: goRouter.routeInformationParser,
+            routerDelegate: goRouter.routerDelegate,
+          ),
+        );
+
+        goRouter.push('/page-0');
+
+        goRouter.routerDelegate.addListener(expectAsync0(() {}));
+        final GoRouteMatch first = goRouter.routerDelegate.matches.first;
+        final GoRouteMatch last = goRouter.routerDelegate.matches.last;
+        goRouter.replace('/page-1');
+        expect(goRouter.routerDelegate.matches.length, 2);
+        expect(
+          goRouter.routerDelegate.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.fullpath,
+          '/page-1',
+          reason: 'The new location should have been pushed',
+        );
+      },
+    );
+  });
+
+  group('replaceNamed', () {
+    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',
+                name: 'page0',
+                builder: (_, __) => const SizedBox()),
+            GoRoute(
+                path: '/page-1',
+                name: 'page1',
+                builder: (_, __) => const SizedBox()),
+          ],
+        );
+        await tester.pumpWidget(
+          MaterialApp.router(
+            routeInformationProvider: goRouter.routeInformationProvider,
+            routeInformationParser: goRouter.routeInformationParser,
+            routerDelegate: goRouter.routerDelegate,
+          ),
+        );
+
+        goRouter.pushNamed('page0');
+
+        goRouter.routerDelegate.addListener(expectAsync0(() {}));
+        final GoRouteMatch first = goRouter.routerDelegate.matches.first;
+        final GoRouteMatch last = goRouter.routerDelegate.matches.last;
+        goRouter.replaceNamed('page1');
+        expect(goRouter.routerDelegate.matches.length, 2);
+        expect(
+          goRouter.routerDelegate.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,
+          isA<GoRouteMatch>()
+              .having(
+                (GoRouteMatch match) => match.fullpath,
+                'match.fullpath',
+                '/page-1',
+              )
+              .having(
+                (GoRouteMatch match) => match.route.name,
+                'match.route.name',
+                'page1',
+              ),
+          reason: 'The new location should have been pushed',
+        );
+      },
+    );
+  });
+
   testWidgets('dispose unsubscribes from refreshListenable',
       (WidgetTester tester) async {
     final FakeRefreshListenable refreshListenable = FakeRefreshListenable();