[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));
+ });
}