[go_router] Add support for relative routes (#6825)

Add supports for relative routes by allowing going to a path relatively, like go('./$path')

This PR doesn't fully resolve any issue, but it's mandatory to further add examples & tests for `TypedRelativeGoRoute` (see [#7174](https://github.com/flutter/packages/pull/6823)), which will resolves [#108177](https://github.com/flutter/flutter/issues/108177)
diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md
index edd933b..a611c95 100644
--- a/packages/go_router/CHANGELOG.md
+++ b/packages/go_router/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 14.6.0
+
+- Allows going to a path relatively by prefixing `./`
+
 ## 14.5.0
 
 - Adds preload support to StatefulShellRoute, configurable via `preload` parameter on StatefulShellBranch.
diff --git a/packages/go_router/example/lib/go_relative.dart b/packages/go_router/example/lib/go_relative.dart
new file mode 100644
index 0000000..72076dc
--- /dev/null
+++ b/packages/go_router/example/lib/go_relative.dart
@@ -0,0 +1,128 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+
+/// This sample app demonstrates how to use go relatively with GoRouter.go('./$path').
+void main() => runApp(const MyApp());
+
+/// The main app.
+class MyApp extends StatelessWidget {
+  /// Constructs a [MyApp]
+  const MyApp({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp.router(
+      routerConfig: _router,
+    );
+  }
+}
+
+/// The route configuration.
+final GoRouter _router = GoRouter(
+  routes: <RouteBase>[
+    GoRoute(
+      path: '/',
+      builder: (BuildContext context, GoRouterState state) {
+        return const HomeScreen();
+      },
+      routes: <RouteBase>[
+        GoRoute(
+          path: 'details',
+          builder: (BuildContext context, GoRouterState state) {
+            return const DetailsScreen();
+          },
+          routes: <RouteBase>[
+            GoRoute(
+              path: 'settings',
+              builder: (BuildContext context, GoRouterState state) {
+                return const SettingsScreen();
+              },
+            ),
+          ],
+        ),
+      ],
+    ),
+  ],
+);
+
+/// The home screen
+class HomeScreen extends StatelessWidget {
+  /// Constructs a [HomeScreen]
+  const HomeScreen({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(title: const Text('Home Screen')),
+      body: Center(
+        child: Column(
+          mainAxisAlignment: MainAxisAlignment.center,
+          children: <Widget>[
+            ElevatedButton(
+              onPressed: () => context.go('./details'),
+              child: const Text('Go to the Details screen'),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}
+
+/// The details screen
+class DetailsScreen extends StatelessWidget {
+  /// Constructs a [DetailsScreen]
+  const DetailsScreen({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(title: const Text('Details Screen')),
+      body: Center(
+        child: Column(
+          children: <Widget>[
+            TextButton(
+              onPressed: () {
+                context.pop();
+              },
+              child: const Text('Go back'),
+            ),
+            TextButton(
+              onPressed: () {
+                context.go('./settings');
+              },
+              child: const Text('Go to the Settings screen'),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}
+
+/// The settings screen
+class SettingsScreen extends StatelessWidget {
+  /// Constructs a [SettingsScreen]
+  const SettingsScreen({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(title: const Text('Settings Screen')),
+      body: Column(
+        children: <Widget>[
+          TextButton(
+            onPressed: () {
+              context.pop();
+            },
+            child: const Text('Go back'),
+          ),
+        ],
+      ),
+    );
+  }
+}
diff --git a/packages/go_router/example/test/go_relative_test.dart b/packages/go_router/example/test/go_relative_test.dart
new file mode 100644
index 0000000..ee18d01
--- /dev/null
+++ b/packages/go_router/example/test/go_relative_test.dart
@@ -0,0 +1,29 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:go_router_examples/go_relative.dart' as example;
+
+void main() {
+  testWidgets('example works', (WidgetTester tester) async {
+    await tester.pumpWidget(const example.MyApp());
+    expect(find.byType(example.HomeScreen), findsOneWidget);
+
+    await tester.tap(find.text('Go to the Details screen'));
+    await tester.pumpAndSettle();
+    expect(find.byType(example.DetailsScreen), findsOneWidget);
+
+    await tester.tap(find.text('Go to the Settings screen'));
+    await tester.pumpAndSettle();
+    expect(find.byType(example.SettingsScreen), findsOneWidget);
+
+    await tester.tap(find.text('Go back'));
+    await tester.pumpAndSettle();
+    expect(find.byType(example.DetailsScreen), findsOneWidget);
+
+    await tester.tap(find.text('Go back'));
+    await tester.pumpAndSettle();
+    expect(find.byType(example.HomeScreen), findsOneWidget);
+  });
+}
diff --git a/packages/go_router/lib/src/information_provider.dart b/packages/go_router/lib/src/information_provider.dart
index 75d9dec..400ead0 100644
--- a/packages/go_router/lib/src/information_provider.dart
+++ b/packages/go_router/lib/src/information_provider.dart
@@ -10,6 +10,7 @@
 import 'package:flutter/widgets.dart';
 
 import 'match.dart';
+import 'path_utils.dart';
 
 /// The type of the navigation.
 ///
@@ -139,11 +140,16 @@
   }
 
   void _setValue(String location, Object state) {
-    final Uri uri = Uri.parse(location);
+    Uri uri = Uri.parse(location);
+
+    // Check for relative location
+    if (location.startsWith('./')) {
+      uri = concatenateUris(_value.uri, uri);
+    }
 
     final bool shouldNotify =
         _valueHasChanged(newLocationUri: uri, newState: state);
-    _value = RouteInformation(uri: Uri.parse(location), state: state);
+    _value = RouteInformation(uri: uri, state: state);
     if (shouldNotify) {
       notifyListeners();
     }
diff --git a/packages/go_router/lib/src/path_utils.dart b/packages/go_router/lib/src/path_utils.dart
index 4684c75..8bcb62a 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+)(\((?:\\.|[^\\()])+\))?');
@@ -112,6 +113,54 @@
   return '/${segments.join('/')}';
 }
 
+/// Concatenates two Uri. It will [concatenatePaths] the parent's and the child's paths, and take only the child's parameters.
+///
+/// e.g: pathA = /a?fid=f1, pathB = c/d?pid=p2,  concatenatePaths(pathA, pathB) = /a/c/d?pid=2.
+Uri concatenateUris(Uri parentUri, Uri childUri) {
+  Uri newUri = childUri.replace(
+    path: concatenatePaths(parentUri.path, childUri.path),
+  );
+
+  // Parse the new normalized uri to remove unnecessary parts, like the trailing '?'.
+  newUri = Uri.parse(canonicalUri(newUri.toString()));
+  return newUri;
+}
+
+/// 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;
+  final Uri uri = Uri.parse(canon);
+
+  // remove trailing slash except for when you shouldn't, e.g.
+  // /profile/ => /profile
+  // / => /
+  // /login?from=/ => /login?from=/
+  canon = uri.path.endsWith('/') &&
+          uri.path != '/' &&
+          !uri.hasQuery &&
+          !uri.hasFragment
+      ? canon.substring(0, canon.length - 1)
+      : canon;
+
+  // replace '/?', except for first occurrence, from path only
+  // /login/?from=/ => /login?from=/
+  // /?from=/ => /?from=/
+  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;
+}
+
 /// Builds an absolute path for the provided route.
 String? fullPathForRoute(
     RouteBase targetRoute, String parentFullpath, List<RouteBase> routes) {
diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml
index 94c1899..8179c8a 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: 14.5.0
+version: 14.6.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_test.dart b/packages/go_router/test/go_router_test.dart
index 77928e7..2e92c56 100644
--- a/packages/go_router/test/go_router_test.dart
+++ b/packages/go_router/test/go_router_test.dart
@@ -1845,6 +1845,283 @@
     });
   });
 
+  group('go relative', () {
+    testWidgets('from default route', (WidgetTester tester) async {
+      final List<GoRoute> routes = <GoRoute>[
+        GoRoute(
+          path: '/',
+          builder: (BuildContext context, GoRouterState state) =>
+              const HomeScreen(),
+          routes: <GoRoute>[
+            GoRoute(
+              path: 'login',
+              builder: (BuildContext context, GoRouterState state) =>
+                  const LoginScreen(),
+            ),
+          ],
+        ),
+      ];
+
+      final GoRouter router = await createRouter(routes, tester);
+      router.go('./login');
+      await tester.pumpAndSettle();
+      expect(find.byType(LoginScreen), findsOneWidget);
+    });
+
+    testWidgets('from non-default route', (WidgetTester tester) async {
+      final List<GoRoute> routes = <GoRoute>[
+        GoRoute(
+          path: '/home',
+          builder: (BuildContext context, GoRouterState state) =>
+              const HomeScreen(),
+          routes: <GoRoute>[
+            GoRoute(
+              path: 'login',
+              builder: (BuildContext context, GoRouterState state) =>
+                  const LoginScreen(),
+            ),
+          ],
+        ),
+      ];
+
+      final GoRouter router = await createRouter(routes, tester);
+      router.go('/home');
+      router.go('./login');
+      await tester.pumpAndSettle();
+      expect(find.byType(LoginScreen), findsOneWidget);
+    });
+
+    testWidgets('match w/ path params', (WidgetTester tester) async {
+      const String fid = 'f2';
+      const String pid = 'p1';
+
+      final List<GoRoute> routes = <GoRoute>[
+        GoRoute(
+          path: '/home',
+          builder: (BuildContext context, GoRouterState state) =>
+              const HomeScreen(),
+          routes: <GoRoute>[
+            GoRoute(
+              path: 'family/:fid',
+              builder: (BuildContext context, GoRouterState state) =>
+                  const FamilyScreen('dummy'),
+              routes: <GoRoute>[
+                GoRoute(
+                  name: 'person',
+                  path: 'person/:pid',
+                  builder: (BuildContext context, GoRouterState state) {
+                    expect(state.pathParameters,
+                        <String, String>{'fid': fid, 'pid': pid});
+                    return const PersonScreen('dummy', 'dummy');
+                  },
+                ),
+              ],
+            ),
+          ],
+        ),
+      ];
+
+      final GoRouter router =
+          await createRouter(routes, tester, initialLocation: '/home');
+
+      router.go('./family/$fid');
+      await tester.pumpAndSettle();
+      expect(find.byType(FamilyScreen), findsOneWidget);
+
+      router.go('./person/$pid');
+      await tester.pumpAndSettle();
+      expect(find.byType(PersonScreen), findsOneWidget);
+    });
+
+    testWidgets('match w/ query params', (WidgetTester tester) async {
+      const String fid = 'f2';
+      const String pid = 'p1';
+
+      final List<GoRoute> routes = <GoRoute>[
+        GoRoute(
+          path: '/home',
+          builder: (BuildContext context, GoRouterState state) =>
+              const HomeScreen(),
+          routes: <GoRoute>[
+            GoRoute(
+              path: 'family',
+              builder: (BuildContext context, GoRouterState state) =>
+                  const FamilyScreen('dummy'),
+              routes: <GoRoute>[
+                GoRoute(
+                  path: 'person',
+                  builder: (BuildContext context, GoRouterState state) {
+                    expect(state.uri.queryParameters,
+                        <String, String>{'pid': pid});
+                    return const PersonScreen('dummy', 'dummy');
+                  },
+                ),
+              ],
+            ),
+          ],
+        ),
+      ];
+
+      final GoRouter router =
+          await createRouter(routes, tester, initialLocation: '/home');
+
+      router.go('./family?fid=$fid');
+      await tester.pumpAndSettle();
+      expect(find.byType(FamilyScreen), findsOneWidget);
+
+      router.go('./person?pid=$pid');
+      await tester.pumpAndSettle();
+      expect(find.byType(PersonScreen), findsOneWidget);
+    });
+
+    testWidgets('too few params', (WidgetTester tester) async {
+      const String pid = 'p1';
+
+      final List<GoRoute> routes = <GoRoute>[
+        GoRoute(
+          path: '/home',
+          builder: (BuildContext context, GoRouterState state) =>
+              const HomeScreen(),
+          routes: <GoRoute>[
+            GoRoute(
+              path: 'family/:fid',
+              builder: (BuildContext context, GoRouterState state) =>
+                  const FamilyScreen('dummy'),
+              routes: <GoRoute>[
+                GoRoute(
+                  path: 'person/:pid',
+                  builder: (BuildContext context, GoRouterState state) =>
+                      const PersonScreen('dummy', 'dummy'),
+                ),
+              ],
+            ),
+          ],
+        ),
+      ];
+      // await expectLater(() async {
+      final GoRouter router = await createRouter(
+        routes,
+        tester,
+        initialLocation: '/home',
+        errorBuilder: (BuildContext context, GoRouterState state) =>
+            TestErrorScreen(state.error!),
+      );
+      router.go('./family/person/$pid');
+      await tester.pumpAndSettle();
+      expect(find.byType(TestErrorScreen), findsOneWidget);
+
+      final List<RouteMatchBase> matches =
+          router.routerDelegate.currentConfiguration.matches;
+      expect(matches, hasLength(0));
+    });
+
+    testWidgets('match no route', (WidgetTester tester) async {
+      final List<GoRoute> routes = <GoRoute>[
+        GoRoute(
+          path: '/home',
+          builder: (BuildContext context, GoRouterState state) =>
+              const HomeScreen(),
+          routes: <GoRoute>[
+            GoRoute(
+              path: 'family',
+              builder: (BuildContext context, GoRouterState state) =>
+                  const FamilyScreen('dummy'),
+              routes: <GoRoute>[
+                GoRoute(
+                  path: 'person',
+                  builder: (BuildContext context, GoRouterState state) =>
+                      const PersonScreen('dummy', 'dummy'),
+                ),
+              ],
+            ),
+          ],
+        ),
+      ];
+
+      final GoRouter router = await createRouter(
+        routes,
+        tester,
+        initialLocation: '/home',
+        errorBuilder: (BuildContext context, GoRouterState state) =>
+            TestErrorScreen(state.error!),
+      );
+      router.go('person');
+
+      await tester.pumpAndSettle();
+      expect(find.byType(TestErrorScreen), findsOneWidget);
+
+      final List<RouteMatchBase> matches =
+          router.routerDelegate.currentConfiguration.matches;
+      expect(matches, hasLength(0));
+    });
+
+    testWidgets('preserve path param spaces and slashes',
+        (WidgetTester tester) async {
+      const String param1 = 'param w/ spaces and slashes';
+      final List<GoRoute> routes = <GoRoute>[
+        GoRoute(
+          path: '/home',
+          builder: dummy,
+          routes: <RouteBase>[
+            GoRoute(
+              path: 'page1/:param1',
+              builder: (BuildContext c, GoRouterState s) {
+                expect(s.pathParameters['param1'], param1);
+                return const DummyScreen();
+              },
+            ),
+          ],
+        )
+      ];
+
+      final GoRouter router =
+          await createRouter(routes, tester, initialLocation: '/home');
+      final String loc = 'page1/${Uri.encodeComponent(param1)}';
+      router.go('./$loc');
+
+      await tester.pumpAndSettle();
+      expect(find.byType(DummyScreen), findsOneWidget);
+
+      final RouteMatchList matches = router.routerDelegate.currentConfiguration;
+      expect(matches.pathParameters['param1'], param1);
+    });
+
+    testWidgets('preserve query param spaces and slashes',
+        (WidgetTester tester) async {
+      const String param1 = 'param w/ spaces and slashes';
+      final List<GoRoute> routes = <GoRoute>[
+        GoRoute(
+          path: '/home',
+          builder: dummy,
+          routes: <RouteBase>[
+            GoRoute(
+              path: 'page1',
+              builder: (BuildContext c, GoRouterState s) {
+                expect(s.uri.queryParameters['param1'], param1);
+                return const DummyScreen();
+              },
+            ),
+          ],
+        )
+      ];
+
+      final GoRouter router =
+          await createRouter(routes, tester, initialLocation: '/home');
+
+      final String loc = Uri(
+        path: 'page1',
+        queryParameters: <String, dynamic>{'param1': param1},
+      ).toString();
+      router.go('./$loc');
+
+      await tester.pumpAndSettle();
+      expect(find.byType(DummyScreen), findsOneWidget);
+
+      final RouteMatchList matches = router.routerDelegate.currentConfiguration;
+      expect(matches.uri.queryParameters['param1'], param1);
+    });
+  });
+
   group('redirects', () {
     testWidgets('top-level redirect', (WidgetTester tester) async {
       final List<GoRoute> routes = <GoRoute>[
diff --git a/packages/go_router/test/path_utils_test.dart b/packages/go_router/test/path_utils_test.dart
index 5b0b123..b13599c 100644
--- a/packages/go_router/test/path_utils_test.dart
+++ b/packages/go_router/test/path_utils_test.dart
@@ -86,4 +86,47 @@
     verify('/', '/', '/');
     verify('', '', '/');
   });
+
+  test('concatenateUris', () {
+    void verify(String pathA, String pathB, String expected) {
+      final String result =
+          concatenateUris(Uri.parse(pathA), Uri.parse(pathB)).toString();
+      expect(result, expected);
+    }
+
+    verify('/a', 'b/c', '/a/b/c');
+    verify('/', 'b', '/b');
+
+    // Test with parameters
+    verify('/a?fid=f1', 'b/c?', '/a/b/c');
+    verify('/a', 'b/c?pid=p2', '/a/b/c?pid=p2');
+    verify('/a?fid=f1', 'b/c?pid=p2', '/a/b/c?pid=p2');
+
+    // Test with fragment
+    verify('/a#f', 'b/c#f2', '/a/b/c#f2');
+
+    // Test with fragment and parameters
+    verify('/a?fid=f1#f', 'b/c?pid=p2#', '/a/b/c?pid=p2#');
+  });
+
+  test('canonicalUri', () {
+    void verify(String path, String expected) =>
+        expect(canonicalUri(path), expected);
+    verify('/a', '/a');
+    verify('/a/', '/a');
+    verify('/', '/');
+    verify('/a/b/', '/a/b');
+    verify('https://www.example.com/', 'https://www.example.com/');
+    verify('https://www.example.com/a', 'https://www.example.com/a');
+    verify('https://www.example.com/a/', 'https://www.example.com/a');
+    verify('https://www.example.com/a/b/', 'https://www.example.com/a/b');
+    verify('https://www.example.com/?', 'https://www.example.com/');
+    verify('https://www.example.com/?a=b', 'https://www.example.com/?a=b');
+    verify('https://www.example.com/?a=/', 'https://www.example.com/?a=/');
+    verify('https://www.example.com/a/?b=c', 'https://www.example.com/a?b=c');
+    verify('https://www.example.com/#a/', 'https://www.example.com/#a/');
+
+    expect(() => canonicalUri('::::'), throwsA(isA<FormatException>()));
+    expect(() => canonicalUri(''), throwsA(anything));
+  });
 }