From 6660212f2262adb7f423fd2b51d05458e4a534d8 Mon Sep 17 00:00:00 2001 From: chunhtai <47866232+chunhtai@users.noreply.github.com> Date: Thu, 21 Dec 2023 11:44:10 -0800 Subject: [PATCH] [go_router] Refactored RouteMatchList and imperative APIs (#5497) This pr refactor RouteMatchList to be a tree structure. Added a common base class RouteMatchBase. It is extended by both RouteMatch and ShellRouteMatch. The RouteMatch is for GoRoute, and is always a leaf node The ShellRouteMatch is for ShellRouteBase, and is always and intermediate node with a list of child RouteMatchBase[s]. This pr also redo how push is processed. Will add a doc explain this shortly. This is a breaking change, will write a migration guide soon. fixes https://github.com/flutter/flutter/issues/134524 fixes https://github.com/flutter/flutter/issues/130406 fixes https://github.com/flutter/flutter/issues/126365 fixes https://github.com/flutter/flutter/issues/125752 fixes https://github.com/flutter/flutter/issues/120791 fixes https://github.com/flutter/flutter/issues/120665 fixes https://github.com/flutter/flutter/issues/113001 fixes https://github.com/flutter/flutter/issues/110512 --- packages/go_router/CHANGELOG.md | 7 + packages/go_router/README.md | 2 +- packages/go_router/doc/navigation.md | 21 + .../example/lib/push_with_shell_route.dart | 160 +++++ .../test/push_with_shell_route_test.dart | 45 ++ packages/go_router/lib/src/builder.dart | 587 ++++++---------- packages/go_router/lib/src/configuration.dart | 136 +--- packages/go_router/lib/src/delegate.dart | 172 ++--- packages/go_router/lib/src/match.dart | 654 +++++++++++++++--- .../go_router/lib/src/pages/cupertino.dart | 4 +- .../go_router/lib/src/pages/material.dart | 4 +- packages/go_router/lib/src/parser.dart | 3 +- packages/go_router/lib/src/route.dart | 51 +- packages/go_router/pubspec.yaml | 2 +- packages/go_router/test/builder_test.dart | 88 ++- packages/go_router/test/delegate_test.dart | 36 +- packages/go_router/test/go_router_test.dart | 58 +- .../go_router/test/imperative_api_test.dart | 297 ++++++++ packages/go_router/test/inherited_test.dart | 27 + packages/go_router/test/match_test.dart | 176 +++-- packages/go_router/test/matching_test.dart | 24 +- packages/go_router/test/parser_test.dart | 18 +- packages/go_router/test/test_helpers.dart | 8 - 23 files changed, 1688 insertions(+), 892 deletions(-) create mode 100644 packages/go_router/example/lib/push_with_shell_route.dart create mode 100644 packages/go_router/example/test/push_with_shell_route_test.dart create mode 100644 packages/go_router/test/imperative_api_test.dart diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index d7d52ddf96cc..911de5304ffc 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,10 @@ +## 13.0.0 + +- Refactors `RouteMatchList` and imperative APIs. +- **BREAKING CHANGE**: + - RouteMatchList structure changed. + - Matching logic updated. + ## 12.1.3 * Fixes a typo in `navigation.md`. diff --git a/packages/go_router/README.md b/packages/go_router/README.md index 51cda37b0b99..a9f9b6b27111 100644 --- a/packages/go_router/README.md +++ b/packages/go_router/README.md @@ -37,6 +37,7 @@ See the API documentation for details on the following topics: - [Error handling](https://pub.dev/documentation/go_router/latest/topics/Error%20handling-topic.html) ## Migration Guides +- [Migrating to 13.0.0](https://flutter.dev/go/go-router-v13-breaking-changes). - [Migrating to 12.0.0](https://flutter.dev/go/go-router-v12-breaking-changes). - [Migrating to 11.0.0](https://flutter.dev/go/go-router-v11-breaking-changes). - [Migrating to 10.0.0](https://flutter.dev/go/go-router-v10-breaking-changes). @@ -67,4 +68,3 @@ The project follows the same priority system as flutter framework. [P3](https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-asc+label%3Ateam-go_router+label%3AP3+) [Package PRs](https://github.com/flutter/packages/pulls?q=is%3Apr+is%3Aopen+label%3A%22p%3A+go_router%22%2C%22p%3A+go_router_builder%22) - diff --git a/packages/go_router/doc/navigation.md b/packages/go_router/doc/navigation.md index b0ad5249f80e..74bc86631ca9 100644 --- a/packages/go_router/doc/navigation.md +++ b/packages/go_router/doc/navigation.md @@ -68,6 +68,27 @@ Navigator.of(context).push( ); ``` +The behavior may change depends on the shell route in current screen and the new screen. + +If pushing a new screen without any shell route onto the current screen with shell route, the new +screen is placed entirely on top of the current screen. + +![An animation shows a new screen push on top of current screen](https://flutter.github.io/assets-for-api-docs/assets/go_router/push_regular_route.gif) + +If pushing a new screen with the same shell route as the current screen, the new +screen is placed inside of the shell. + +![An animation shows pushing a new screen with the same shell as current screen](https://flutter.github.io/assets-for-api-docs/assets/go_router/push_same_shell.gif) + +If pushing a new screen with the different shell route as the current screen, the new +screen along with the shell is placed entirely on top of the current screen. + +![An animation shows pushing a new screen with the different shell as current screen](https://flutter.github.io/assets-for-api-docs/assets/go_router/push_different_shell.gif) + +To try out the behavior yourself, see +[push_with_shell_route.dart](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/extra_codec.dart). + + ## Returning values Waiting for a value to be returned: diff --git a/packages/go_router/example/lib/push_with_shell_route.dart b/packages/go_router/example/lib/push_with_shell_route.dart new file mode 100644 index 000000000000..d26fb104294e --- /dev/null +++ b/packages/go_router/example/lib/push_with_shell_route.dart @@ -0,0 +1,160 @@ +// 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 scenario demonstrates the behavior when pushing ShellRoute in various +// scenario. +// +// This example have three routes, /shell1, /shell2, and /regular-route. The +// /shell1 and /shell2 are nested in different ShellRoutes. The /regular-route +// is a simple GoRoute. + +void main() { + runApp(PushWithShellRouteExampleApp()); +} + +/// An example demonstrating how to use [ShellRoute] +class PushWithShellRouteExampleApp extends StatelessWidget { + /// Creates a [PushWithShellRouteExampleApp] + PushWithShellRouteExampleApp({super.key}); + + final GoRouter _router = GoRouter( + initialLocation: '/home', + debugLogDiagnostics: true, + routes: [ + ShellRoute( + builder: (BuildContext context, GoRouterState state, Widget child) { + return ScaffoldForShell1(child: child); + }, + routes: [ + GoRoute( + path: '/home', + builder: (BuildContext context, GoRouterState state) { + return const Home(); + }, + ), + GoRoute( + path: '/shell1', + pageBuilder: (_, __) => const NoTransitionPage( + child: Center( + child: Text('shell1 body'), + ), + ), + ), + ], + ), + ShellRoute( + builder: (BuildContext context, GoRouterState state, Widget child) { + return ScaffoldForShell2(child: child); + }, + routes: [ + GoRoute( + path: '/shell2', + builder: (BuildContext context, GoRouterState state) { + return const Center(child: Text('shell2 body')); + }, + ), + ], + ), + GoRoute( + path: '/regular-route', + builder: (BuildContext context, GoRouterState state) { + return const Scaffold( + body: Center(child: Text('regular route')), + ); + }, + ), + ], + ); + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + title: 'Flutter Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + routerConfig: _router, + ); + } +} + +/// Builds the "shell" for /shell1 +class ScaffoldForShell1 extends StatelessWidget { + /// Constructs an [ScaffoldForShell1]. + const ScaffoldForShell1({ + required this.child, + super.key, + }); + + /// The widget to display in the body of the Scaffold. + /// In this sample, it is a Navigator. + final Widget child; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('shell1')), + body: child, + ); + } +} + +/// Builds the "shell" for /shell1 +class ScaffoldForShell2 extends StatelessWidget { + /// Constructs an [ScaffoldForShell1]. + const ScaffoldForShell2({ + required this.child, + super.key, + }); + + /// The widget to display in the body of the Scaffold. + /// In this sample, it is a Navigator. + final Widget child; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('shell2')), + body: child, + ); + } +} + +/// The screen for /home +class Home extends StatelessWidget { + /// Constructs a [Home] widget. + const Home({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () { + GoRouter.of(context).push('/shell1'); + }, + child: const Text('push the same shell route /shell1'), + ), + TextButton( + onPressed: () { + GoRouter.of(context).push('/shell2'); + }, + child: const Text('push the different shell route /shell2'), + ), + TextButton( + onPressed: () { + GoRouter.of(context).push('/regular-route'); + }, + child: const Text('push the regular route /regular-route'), + ), + ], + ), + ); + } +} diff --git a/packages/go_router/example/test/push_with_shell_route_test.dart b/packages/go_router/example/test/push_with_shell_route_test.dart new file mode 100644 index 000000000000..86d67149b9cb --- /dev/null +++ b/packages/go_router/example/test/push_with_shell_route_test.dart @@ -0,0 +1,45 @@ +// 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/go_router.dart'; +import 'package:go_router_examples/push_with_shell_route.dart' as example; + +void main() { + testWidgets('example works', (WidgetTester tester) async { + await tester.pumpWidget(example.PushWithShellRouteExampleApp()); + expect(find.text('shell1'), findsOneWidget); + + await tester.tap(find.text('push the same shell route /shell1')); + await tester.pumpAndSettle(); + expect(find.text('shell1'), findsOneWidget); + expect(find.text('shell1 body'), findsOneWidget); + + find.text('shell1 body').evaluate().first.pop(); + await tester.pumpAndSettle(); + expect(find.text('shell1'), findsOneWidget); + expect(find.text('shell1 body'), findsNothing); + + await tester.tap(find.text('push the different shell route /shell2')); + await tester.pumpAndSettle(); + expect(find.text('shell1'), findsNothing); + expect(find.text('shell2'), findsOneWidget); + expect(find.text('shell2 body'), findsOneWidget); + + find.text('shell2 body').evaluate().first.pop(); + await tester.pumpAndSettle(); + expect(find.text('shell1'), findsOneWidget); + expect(find.text('shell2'), findsNothing); + + await tester.tap(find.text('push the regular route /regular-route')); + await tester.pumpAndSettle(); + expect(find.text('shell1'), findsNothing); + expect(find.text('regular route'), findsOneWidget); + + find.text('regular route').evaluate().first.pop(); + await tester.pumpAndSettle(); + expect(find.text('shell1'), findsOneWidget); + expect(find.text('regular route'), findsNothing); + }); +} diff --git a/packages/go_router/lib/src/builder.dart b/packages/go_router/lib/src/builder.dart index e3e116fe758c..a7bbc87a3b58 100644 --- a/packages/go_router/lib/src/builder.dart +++ b/packages/go_router/lib/src/builder.dart @@ -2,7 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; import 'configuration.dart'; @@ -23,6 +22,19 @@ typedef GoRouterBuilderWithNav = Widget Function( Widget child, ); +typedef _PageBuilderForAppType = Page Function({ + required LocalKey key, + required String? name, + required Object? arguments, + required String restorationId, + required Widget child, +}); + +typedef _ErrorBuilderForAppType = Widget Function( + BuildContext context, + GoRouterState state, +); + /// Signature for a function that takes in a `route` to be popped with /// the `result` and returns a boolean decision on whether the pop /// is successful. @@ -32,7 +44,7 @@ typedef GoRouterBuilderWithNav = Widget Function( /// /// Used by of [RouteBuilder.onPopPageWithRouteMatch]. typedef PopPageWithRouteMatchCallback = bool Function( - Route route, dynamic result, RouteMatch? match); + Route route, dynamic result, RouteMatchBase match); /// Builds the top-level Navigator for GoRouter. class RouteBuilder { @@ -74,8 +86,6 @@ class RouteBuilder { /// changes. final List observers; - final GoRouterStateRegistry _registry = GoRouterStateRegistry(); - /// A callback called when a `route` produced by `match` is about to be popped /// with the `result`. /// @@ -84,13 +94,6 @@ class RouteBuilder { /// If this method returns false, this builder aborts the pop. final PopPageWithRouteMatchCallback onPopPageWithRouteMatch; - /// Caches a HeroController for the nested Navigator, which solves cases where the - /// Hero Widget animation stops working when navigating. - // TODO(chunhtai): Remove _goHeroCache once below issue is fixed: - // https://github.com/flutter/flutter/issues/54200 - final Map, HeroController> _goHeroCache = - , HeroController>{}; - /// Builds the top-level Navigator for the given [RouteMatchList]. Widget build( BuildContext context, @@ -102,339 +105,204 @@ class RouteBuilder { // empty box until then. return const SizedBox.shrink(); } - assert( - matchList.isError || !(matchList.last.route as GoRoute).redirectOnly); - return builderWithNav( - context, - Builder( - builder: (BuildContext context) { - final Map, GoRouterState> newRegistry = - , GoRouterState>{}; - final Widget result = tryBuild(context, matchList, routerNeglect, - configuration.navigatorKey, newRegistry); - _registry.updateRegistry(newRegistry); - return GoRouterStateRegistryScope(registry: _registry, child: result); - }, - ), - ); - } - - /// Builds the top-level Navigator by invoking the build method on each - /// matching route. - /// - /// Throws a [_RouteBuilderError]. - @visibleForTesting - Widget tryBuild( - BuildContext context, - RouteMatchList matchList, - bool routerNeglect, - GlobalKey navigatorKey, - Map, GoRouterState> registry, - ) { - // TODO(chunhtai): move the state from local scope to a central place. - // https://github.com/flutter/flutter/issues/126365 - final _PagePopContext pagePopContext = - _PagePopContext._(onPopPageWithRouteMatch); + assert(matchList.isError || !matchList.last.route.redirectOnly); return builderWithNav( context, - _buildNavigator( - pagePopContext.onPopPage, - _buildPages(context, matchList, pagePopContext, routerNeglect, - navigatorKey, registry), - navigatorKey, + _CustomNavigator( + navigatorKey: configuration.navigatorKey, observers: observers, - restorationScopeId: restorationScopeId, - requestFocus: requestFocus, + navigatorRestorationId: restorationScopeId, + onPopPageWithRouteMatch: onPopPageWithRouteMatch, + matchList: matchList, + matches: matchList.matches, + configuration: configuration, + errorBuilder: errorBuilder, + errorPageBuilder: errorPageBuilder, ), ); } +} - /// Returns the top-level pages instead of the root navigator. Used for - /// testing. - List> _buildPages( - BuildContext context, - RouteMatchList matchList, - _PagePopContext pagePopContext, - bool routerNeglect, - GlobalKey navigatorKey, - Map, GoRouterState> registry) { - final Map, List>> keyToPage; - if (matchList.isError) { - keyToPage = , List>>{ - navigatorKey: >[ - _buildErrorPage(context, _buildErrorState(matchList)), - ] - }; - } else { - keyToPage = , List>>{}; - _buildRecursive(context, matchList, 0, pagePopContext, routerNeglect, - keyToPage, navigatorKey, registry); - - // Every Page should have a corresponding RouteMatch. - assert(keyToPage.values.flattened.every((Page page) => - pagePopContext.getRouteMatchesForPage(page) != null)); - } +class _CustomNavigator extends StatefulWidget { + const _CustomNavigator({ + super.key, + required this.navigatorKey, + required this.observers, + required this.navigatorRestorationId, + required this.onPopPageWithRouteMatch, + required this.matchList, + required this.matches, + required this.configuration, + required this.errorBuilder, + required this.errorPageBuilder, + }); - /// Clean up previous cache to prevent memory leak, making sure any nested - /// stateful shell routes for the current match list are kept. - final Set activeKeys = keyToPage.keys.toSet() - ..addAll(_nestedStatefulNavigatorKeys(matchList)); - _goHeroCache.removeWhere( - (GlobalKey key, _) => !activeKeys.contains(key)); - return keyToPage[navigatorKey]!; - } + final GlobalKey navigatorKey; + final List observers; - static Set> _nestedStatefulNavigatorKeys( - RouteMatchList matchList) { - final StatefulShellRoute? shellRoute = - matchList.routes.whereType().firstOrNull; - if (shellRoute == null) { - return >{}; - } - return RouteBase.routesRecursively([shellRoute]) - .whereType() - .expand((StatefulShellRoute e) => - e.branches.map((StatefulShellBranch b) => b.navigatorKey)) - .toSet(); - } + /// The actual [RouteMatchBase]s to be built. + /// + /// This can be different from matches in [matchList] if this widget is used + /// to build navigator in shell route. In this case, these matches come from + /// the [ShellRouteMatch.matches]. + final List matches; + final RouteMatchList matchList; + final RouteConfiguration configuration; + final PopPageWithRouteMatchCallback onPopPageWithRouteMatch; + final String? navigatorRestorationId; + final GoRouterWidgetBuilder? errorBuilder; + final GoRouterPageBuilder? errorPageBuilder; - void _buildRecursive( - BuildContext context, - RouteMatchList matchList, - int startIndex, - _PagePopContext pagePopContext, - bool routerNeglect, - Map, List>> keyToPages, - GlobalKey navigatorKey, - Map, GoRouterState> registry, - ) { - if (startIndex >= matchList.matches.length) { - return; - } - final RouteMatch match = matchList.matches[startIndex]; - - final RouteBase route = match.route; - final GoRouterState state = buildState(matchList, match); - Page? page; - if (state.error != null) { - page = _buildErrorPage(context, state); - keyToPages.putIfAbsent(navigatorKey, () => >[]).add(page); - _buildRecursive(context, matchList, startIndex + 1, pagePopContext, - routerNeglect, keyToPages, navigatorKey, registry); - } else { - // If this RouteBase is for a different Navigator, add it to the - // list of out of scope pages - final GlobalKey routeNavKey = - route.parentNavigatorKey ?? navigatorKey; - if (route is GoRoute) { - page = - _buildPageForGoRoute(context, state, match, route, pagePopContext); - assert(page != null || route.redirectOnly); - if (page != null) { - keyToPages - .putIfAbsent(routeNavKey, () => >[]) - .add(page); - } + @override + State createState() => _CustomNavigatorState(); +} - _buildRecursive(context, matchList, startIndex + 1, pagePopContext, - routerNeglect, keyToPages, navigatorKey, registry); - } else if (route is ShellRouteBase) { - assert(startIndex + 1 < matchList.matches.length, - 'Shell routes must always have child routes'); - - // Add an entry for the parent navigator if none exists. - // - // Calling _buildRecursive can result in adding pages to the - // parentNavigatorKey entry's list. Store the current length so - // that the page for this ShellRoute is placed at the right index. - final int shellPageIdx = - keyToPages.putIfAbsent(routeNavKey, () => >[]).length; - - // Find the the navigator key for the sub-route of this shell route. - final RouteBase subRoute = matchList.matches[startIndex + 1].route; - final GlobalKey shellNavigatorKey = - route.navigatorKeyForSubRoute(subRoute); - - keyToPages.putIfAbsent(shellNavigatorKey, () => >[]); - - // Build the remaining pages - _buildRecursive(context, matchList, startIndex + 1, pagePopContext, - routerNeglect, keyToPages, shellNavigatorKey, registry); - - final HeroController heroController = _goHeroCache.putIfAbsent( - shellNavigatorKey, () => _getHeroController(context)); - - // Build the Navigator for this shell route - Widget buildShellNavigator( - List? observers, - String? restorationScopeId, { - bool requestFocus = true, - }) { - return _buildNavigator( - pagePopContext.onPopPage, - keyToPages[shellNavigatorKey]!, - shellNavigatorKey, - observers: observers ?? const [], - restorationScopeId: restorationScopeId, - heroController: heroController, - requestFocus: requestFocus, - ); - } +class _CustomNavigatorState extends State<_CustomNavigator> { + HeroController? _controller; + late Map, RouteMatchBase> _pageToRouteMatchBase; + final GoRouterStateRegistry _registry = GoRouterStateRegistry(); + List>? _pages; - // Call the ShellRouteBase to create/update the shell route state - final ShellRouteContext shellRouteContext = ShellRouteContext( - route: route, - routerState: state, - navigatorKey: shellNavigatorKey, - routeMatchList: matchList, - navigatorBuilder: buildShellNavigator, - ); + @override + void didUpdateWidget(_CustomNavigator oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.matchList != oldWidget.matchList) { + _pages = null; + } + } - // Build the Page for this route - page = _buildPageForShellRoute( - context, state, match, route, pagePopContext, shellRouteContext); - // Place the ShellRoute's Page onto the list for the parent navigator. - keyToPages[routeNavKey]!.insert(shellPageIdx, page); + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Create a HeroController based on the app type. + if (_controller == null) { + if (isMaterialApp(context)) { + _controller = createMaterialHeroController(); + } else if (isCupertinoApp(context)) { + _controller = createCupertinoHeroController(); + } else { + _controller = HeroController(); } } - if (page != null) { - registry[page] = state; - // Insert the route match in reverse order. - pagePopContext._insertRouteMatchAtStartForPage(page, match); - } + // This method can also be called if any of the page builders depend on + // the context. In this case, make sure _pages are rebuilt. + _pages = null; } - static Widget _buildNavigator( - PopPageCallback onPopPage, - List> pages, - Key? navigatorKey, { - List observers = const [], - String? restorationScopeId, - HeroController? heroController, - bool requestFocus = true, - }) { - final Widget navigator = Navigator( - key: navigatorKey, - restorationScopeId: restorationScopeId, - pages: pages, - observers: observers, - onPopPage: onPopPage, - requestFocus: requestFocus, - ); - if (heroController != null) { - return HeroControllerScope( - controller: heroController, - child: navigator, - ); + void _updatePages(BuildContext context) { + assert(_pages == null); + final List> pages = >[]; + final Map, RouteMatchBase> pageToRouteMatchBase = + , RouteMatchBase>{}; + final Map, GoRouterState> registry = + , GoRouterState>{}; + if (widget.matchList.isError) { + pages.add(_buildErrorPage(context, widget.matchList)); } else { - return navigator; + for (final RouteMatchBase match in widget.matches) { + final Page? page = _buildPage(context, match); + if (page == null) { + continue; + } + pages.add(page); + pageToRouteMatchBase[page] = match; + registry[page] = + match.buildState(widget.configuration, widget.matchList); + } } + _pages = pages; + _registry.updateRegistry(registry); + _pageToRouteMatchBase = pageToRouteMatchBase; } - /// Helper method that builds a [GoRouterState] object for the given [match] - /// and [pathParameters]. - @visibleForTesting - GoRouterState buildState(RouteMatchList matchList, RouteMatch match) { - final RouteBase route = match.route; - String? name; - String path = ''; - if (route is GoRoute) { - name = route.name; - path = route.path; - } - final RouteMatchList effectiveMatchList; - if (match is ImperativeRouteMatch) { - effectiveMatchList = match.matches; - if (effectiveMatchList.isError) { - return _buildErrorState(effectiveMatchList); + Page? _buildPage(BuildContext context, RouteMatchBase match) { + if (match is RouteMatch) { + if (match is ImperativeRouteMatch && match.matches.isError) { + return _buildErrorPage(context, match.matches); } - } else { - effectiveMatchList = matchList; - assert(!effectiveMatchList.isError); + return _buildPageForGoRoute(context, match); } - return GoRouterState( - configuration, - uri: effectiveMatchList.uri, - matchedLocation: match.matchedLocation, - name: name, - path: path, - fullPath: effectiveMatchList.fullPath, - pathParameters: - Map.from(effectiveMatchList.pathParameters), - error: effectiveMatchList.error, - extra: effectiveMatchList.extra, - pageKey: match.pageKey, - ); + if (match is ShellRouteMatch) { + return _buildPageForShellRoute(context, match); + } + throw GoError('unknown match type ${match.runtimeType}'); } - /// Builds a [Page] for [GoRoute] - Page? _buildPageForGoRoute(BuildContext context, GoRouterState state, - RouteMatch match, GoRoute route, _PagePopContext pagePopContext) { - // Call the pageBuilder if it's non-null - final GoRouterPageBuilder? pageBuilder = route.pageBuilder; + /// Builds a [Page] for a [RouteMatch] + Page? _buildPageForGoRoute(BuildContext context, RouteMatch match) { + final GoRouterPageBuilder? pageBuilder = match.route.pageBuilder; + final GoRouterState state = + match.buildState(widget.configuration, widget.matchList); if (pageBuilder != null) { final Page page = pageBuilder(context, state); if (page is! NoOpPage) { return page; } } - return _callGoRouteBuilder(context, state, route); - } - /// Calls the user-provided route builder from the [GoRoute]. - Page? _callGoRouteBuilder( - BuildContext context, GoRouterState state, GoRoute route) { - final GoRouterWidgetBuilder? builder = route.builder; + final GoRouterWidgetBuilder? builder = match.route.builder; if (builder == null) { return null; } - return buildPage(context, state, Builder(builder: (BuildContext context) { + return _buildPlatformAdapterPage(context, state, + Builder(builder: (BuildContext context) { return builder(context, state); })); } - /// Builds a [Page] for [ShellRouteBase] + /// Builds a [Page] for a [ShellRouteMatch] Page _buildPageForShellRoute( - BuildContext context, - GoRouterState state, - RouteMatch match, - ShellRouteBase route, - _PagePopContext pagePopContext, - ShellRouteContext shellRouteContext) { - Page? page = route.buildPage(context, state, shellRouteContext); - if (page is NoOpPage) { - page = null; + BuildContext context, + ShellRouteMatch match, + ) { + final GoRouterState state = + match.buildState(widget.configuration, widget.matchList); + final GlobalKey navigatorKey = match.navigatorKey; + final ShellRouteContext shellRouteContext = ShellRouteContext( + route: match.route, + routerState: state, + navigatorKey: navigatorKey, + routeMatchList: widget.matchList, + navigatorBuilder: + (List? observers, String? restorationScopeId) { + return _CustomNavigator( + // The state needs to persist across rebuild. + key: GlobalObjectKey(navigatorKey.hashCode), + navigatorRestorationId: restorationScopeId, + navigatorKey: navigatorKey, + matches: match.matches, + matchList: widget.matchList, + configuration: widget.configuration, + observers: observers ?? const [], + onPopPageWithRouteMatch: widget.onPopPageWithRouteMatch, + // This is used to recursively build pages under this shell route. + errorBuilder: widget.errorBuilder, + errorPageBuilder: widget.errorPageBuilder, + ); + }, + ); + final Page? page = + match.route.buildPage(context, state, shellRouteContext); + if (page != null && page is! NoOpPage) { + return page; } // Return the result of the route's builder() or pageBuilder() - return page ?? - buildPage(context, state, Builder(builder: (BuildContext context) { - return _callShellRouteBaseBuilder( - context, state, route, shellRouteContext); - })); - } - - /// Calls the user-provided route builder from the [ShellRouteBase]. - Widget _callShellRouteBaseBuilder(BuildContext context, GoRouterState state, - ShellRouteBase route, ShellRouteContext? shellRouteContext) { - assert(shellRouteContext != null, - 'ShellRouteContext must be provided for ${route.runtimeType}'); - final Widget? widget = - route.buildWidget(context, state, shellRouteContext!); - if (widget == null) { - throw GoError('No builder provided to ShellRoute: $route'); - } - - return widget; + return _buildPlatformAdapterPage( + context, + state, + Builder( + builder: (BuildContext context) { + return match.route.buildWidget(context, state, shellRouteContext)!; + }, + ), + ); } _PageBuilderForAppType? _pageBuilderForAppType; - Widget Function( - BuildContext context, - GoRouterState state, - )? _errorBuilderForAppType; + _ErrorBuilderForAppType? _errorBuilderForAppType; void _cacheAppType(BuildContext context) { // cache app type-specific page and error builders @@ -456,7 +324,20 @@ class RouteBuilder { (BuildContext c, GoRouterState s) => CupertinoErrorScreen(s.error); } else { log('Using WidgetsApp configuration'); - _pageBuilderForAppType = pageBuilderForWidgetApp; + _pageBuilderForAppType = ({ + required LocalKey key, + required String? name, + required Object? arguments, + required String restorationId, + required Widget child, + }) => + NoTransitionPage( + name: name, + arguments: arguments, + key: key, + restorationId: restorationId, + child: child, + ); _errorBuilderForAppType = (BuildContext c, GoRouterState s) => ErrorScreen(s.error); } @@ -467,8 +348,7 @@ class RouteBuilder { } /// builds the page based on app type, i.e. MaterialApp vs. CupertinoApp - @visibleForTesting - Page buildPage( + Page _buildPlatformAdapterPage( BuildContext context, GoRouterState state, Widget child, @@ -487,26 +367,10 @@ class RouteBuilder { ); } - /// Builds a page without any transitions. - Page pageBuilderForWidgetApp({ - required LocalKey key, - required String? name, - required Object? arguments, - required String restorationId, - required Widget child, - }) => - NoTransitionPage( - name: name, - arguments: arguments, - key: key, - restorationId: restorationId, - child: child, - ); - GoRouterState _buildErrorState(RouteMatchList matchList) { assert(matchList.isError); return GoRouterState( - configuration, + widget.configuration, uri: matchList.uri, matchedLocation: matchList.uri.path, fullPath: matchList.fullPath, @@ -517,7 +381,8 @@ class RouteBuilder { } /// Builds a an error page. - Page _buildErrorPage(BuildContext context, GoRouterState state) { + Page _buildErrorPage(BuildContext context, RouteMatchList matchList) { + final GoRouterState state = _buildErrorState(matchList); assert(state.error != null); // If the error page builder is provided, use that, otherwise, if the error @@ -525,10 +390,10 @@ class RouteBuilder { // MaterialPage). Finally, if nothing is provided, use a default error page // wrapped in the app-specific page. _cacheAppType(context); - final GoRouterWidgetBuilder? errorBuilder = this.errorBuilder; - return errorPageBuilder != null - ? errorPageBuilder!(context, state) - : buildPage( + final GoRouterWidgetBuilder? errorBuilder = widget.errorBuilder; + return widget.errorPageBuilder != null + ? widget.errorPageBuilder!(context, state) + : _buildPlatformAdapterPage( context, state, errorBuilder != null @@ -537,66 +402,30 @@ class RouteBuilder { ); } - /// Return a HeroController based on the app type. - HeroController _getHeroController(BuildContext context) { - if (context is Element) { - if (isMaterialApp(context)) { - return createMaterialHeroController(); - } else if (isCupertinoApp(context)) { - return createCupertinoHeroController(); - } - } - return HeroController(); - } -} - -typedef _PageBuilderForAppType = Page Function({ - required LocalKey key, - required String? name, - required Object? arguments, - required String restorationId, - required Widget child, -}); - -/// Context used to provide a route to page association when popping routes. -class _PagePopContext { - _PagePopContext._(this.onPopPageWithRouteMatch); - - /// A page can be mapped to a RouteMatch list, such as a const page being - /// pushed multiple times. - final Map, List> _routeMatchesLookUp = - , List>{}; - - /// On pop page callback that includes the associated [RouteMatch]. - final PopPageWithRouteMatchCallback onPopPageWithRouteMatch; - - /// Looks for the [RouteMatch] for a given [Page]. - /// - /// The [Page] must have been previously built via the [RouteBuilder] that - /// created this [PagePopContext]; otherwise, this method returns null. - List? getRouteMatchesForPage(Page page) => - _routeMatchesLookUp[page]; - - /// This is called in _buildRecursive to insert route matches in reverse order. - void _insertRouteMatchAtStartForPage(Page page, RouteMatch match) { - _routeMatchesLookUp - .putIfAbsent(page, () => []) - .insert(0, match); + bool _handlePopPage(Route route, Object? result) { + final Page page = route.settings as Page; + final RouteMatchBase match = _pageToRouteMatchBase[page]!; + return widget.onPopPageWithRouteMatch(route, result, match); } - /// Function used as [Navigator.onPopPage] callback when creating Navigators. - /// - /// This function forwards to [onPopPageWithRouteMatch], including the - /// [RouteMatch] associated with the popped route. - /// - /// This assumes always pop the last route match for the page. - bool onPopPage(Route route, dynamic result) { - final Page page = route.settings as Page; - final RouteMatch match = _routeMatchesLookUp[page]!.last; - if (onPopPageWithRouteMatch(route, result, match)) { - _routeMatchesLookUp[page]!.removeLast(); - return true; + @override + Widget build(BuildContext context) { + if (_pages == null) { + _updatePages(context); } - return false; + assert(_pages != null); + return GoRouterStateRegistryScope( + registry: _registry, + child: HeroControllerScope( + controller: _controller!, + child: Navigator( + key: widget.navigatorKey, + restorationScopeId: widget.navigatorRestorationId, + pages: _pages!, + observers: widget.observers, + onPopPage: _handlePopPage, + ), + ), + ); } } diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index 5149d18e8520..e7ab54c0ed6b 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -295,9 +295,10 @@ class RouteConfiguration { final Uri uri = Uri.parse(canonicalUri(location)); final Map pathParameters = {}; - final List? matches = _getLocRouteMatches(uri, pathParameters); + final List matches = + _getLocRouteMatches(uri, pathParameters); - if (matches == null) { + if (matches.isEmpty) { return _errorRouteMatchList( uri, GoException('no routes for location: $uri'), @@ -328,96 +329,20 @@ class RouteConfiguration { return result; } - List? _getLocRouteMatches( + List _getLocRouteMatches( Uri uri, Map pathParameters) { - final List? result = _getLocRouteRecursively( - location: uri.path, - remainingLocation: uri.path, - matchedLocation: '', - matchedPath: '', - pathParameters: pathParameters, - routes: _routingConfig.value.routes, - ); - return result; - } - - List? _getLocRouteRecursively({ - required String location, - required String remainingLocation, - required String matchedLocation, - required String matchedPath, - required Map pathParameters, - required List routes, - }) { - List? result; - late Map subPathParameters; - // find the set of matches at this level of the tree - for (final RouteBase route in routes) { - subPathParameters = {}; - - final RouteMatch? match = RouteMatch.match( + for (final RouteBase route in _routingConfig.value.routes) { + final List result = RouteMatchBase.match( + rootNavigatorKey: navigatorKey, route: route, - remainingLocation: remainingLocation, - matchedLocation: matchedLocation, - matchedPath: matchedPath, - pathParameters: subPathParameters, + uri: uri, + pathParameters: pathParameters, ); - - if (match == null) { - continue; + if (result.isNotEmpty) { + return result; } - - if (match.route is GoRoute && - match.matchedLocation.toLowerCase() == location.toLowerCase()) { - // If it is a complete match, then return the matched route - // NOTE: need a lower case match because matchedLocation is canonicalized to match - // the path case whereas the location can be of any case and still match - result = [match]; - } else if (route.routes.isEmpty) { - // If it is partial match but no sub-routes, bail. - continue; - } else { - // Otherwise, recurse - final String childRestLoc; - final String newParentSubLoc; - final String newParentPath; - if (match.route is ShellRouteBase) { - childRestLoc = remainingLocation; - newParentSubLoc = matchedLocation; - newParentPath = matchedPath; - } else { - assert(location.startsWith(match.matchedLocation)); - assert(remainingLocation.isNotEmpty); - - childRestLoc = location.substring(match.matchedLocation.length + - (match.matchedLocation == '/' ? 0 : 1)); - newParentSubLoc = match.matchedLocation; - newParentPath = - concatenatePaths(matchedPath, (match.route as GoRoute).path); - } - - final List? subRouteMatch = _getLocRouteRecursively( - location: location, - remainingLocation: childRestLoc, - matchedLocation: newParentSubLoc, - matchedPath: newParentPath, - pathParameters: subPathParameters, - routes: route.routes, - ); - - // If there's no sub-route matches, there is no match for this location - if (subRouteMatch == null) { - continue; - } - result = [match, ...subRouteMatch]; - } - // Should only reach here if there is a match. - break; - } - if (result != null) { - pathParameters.addAll(subPathParameters); } - return result; + return const []; } /// Processes redirects by returning a new [RouteMatchList] representing the new @@ -468,8 +393,17 @@ class RouteConfiguration { return prevMatchList; } + final List routeMatches = []; + prevMatchList.visitRouteMatches((RouteMatchBase match) { + if (match is RouteMatch) { + routeMatches.add(match); + } + return true; + }); + final FutureOr routeLevelRedirectResult = - _getRouteLevelRedirect(context, prevMatchList, 0); + _getRouteLevelRedirect(context, prevMatchList, routeMatches, 0); + if (routeLevelRedirectResult is String?) { return processRouteLevelRedirect(routeLevelRedirectResult); } @@ -499,33 +433,23 @@ class RouteConfiguration { FutureOr _getRouteLevelRedirect( BuildContext context, RouteMatchList matchList, + List routeMatches, int currentCheckIndex, ) { - if (currentCheckIndex >= matchList.matches.length) { + if (currentCheckIndex >= routeMatches.length) { return null; } - final RouteMatch match = matchList.matches[currentCheckIndex]; + final RouteMatch match = routeMatches[currentCheckIndex]; FutureOr processRouteRedirect(String? newLocation) => newLocation ?? - _getRouteLevelRedirect(context, matchList, currentCheckIndex + 1); - final RouteBase route = match.route; + _getRouteLevelRedirect( + context, matchList, routeMatches, currentCheckIndex + 1); + final GoRoute route = match.route; FutureOr routeRedirectResult; - if (route is GoRoute && route.redirect != null) { - final RouteMatchList effectiveMatchList = - match is ImperativeRouteMatch ? match.matches : matchList; + if (route.redirect != null) { routeRedirectResult = route.redirect!( context, - GoRouterState( - this, - uri: effectiveMatchList.uri, - matchedLocation: match.matchedLocation, - name: route.name, - path: route.path, - fullPath: effectiveMatchList.fullPath, - extra: effectiveMatchList.extra, - pathParameters: effectiveMatchList.pathParameters, - pageKey: match.pageKey, - ), + match.buildState(this, matchList), ); } if (routeRedirectResult is String?) { diff --git a/packages/go_router/lib/src/delegate.dart b/packages/go_router/lib/src/delegate.dart index 756eba28d791..3b01b9e381c8 100644 --- a/packages/go_router/lib/src/delegate.dart +++ b/packages/go_router/lib/src/delegate.dart @@ -50,21 +50,27 @@ class GoRouterDelegate extends RouterDelegate final RouteConfiguration _configuration; - _NavigatorStateIterator _createNavigatorStateIterator() => - _NavigatorStateIterator(currentConfiguration, navigatorKey.currentState!); - @override Future popRoute() async { - final _NavigatorStateIterator iterator = _createNavigatorStateIterator(); - while (iterator.moveNext()) { - final bool didPop = await iterator.current.maybePop(); - if (didPop) { - return true; + NavigatorState? state = navigatorKey.currentState; + if (state == null) { + return false; + } + if (!state.canPop()) { + state = null; + } + RouteMatchBase walker = currentConfiguration.matches.last; + while (walker is ShellRouteMatch) { + if (walker.navigatorKey.currentState?.canPop() ?? false) { + state = walker.navigatorKey.currentState; } + walker = walker.matches.last; + } + if (state != null) { + return state.maybePop(); } // This should be the only place where the last GoRoute exit the screen. - final GoRoute lastRoute = - currentConfiguration.matches.last.route as GoRoute; + final GoRoute lastRoute = currentConfiguration.last.route; if (lastRoute.onExit != null && navigatorKey.currentContext != null) { return !(await lastRoute.onExit!(navigatorKey.currentContext!)); } @@ -73,25 +79,36 @@ class GoRouterDelegate extends RouterDelegate /// Returns `true` if the active Navigator can pop. bool canPop() { - final _NavigatorStateIterator iterator = _createNavigatorStateIterator(); - while (iterator.moveNext()) { - if (iterator.current.canPop()) { + if (navigatorKey.currentState?.canPop() ?? false) { + return true; + } + RouteMatchBase walker = currentConfiguration.matches.last; + while (walker is ShellRouteMatch) { + if (walker.navigatorKey.currentState?.canPop() ?? false) { return true; } + walker = walker.matches.last; } return false; } /// Pops the top-most route. void pop([T? result]) { - final _NavigatorStateIterator iterator = _createNavigatorStateIterator(); - while (iterator.moveNext()) { - if (iterator.current.canPop()) { - iterator.current.pop(result); - return; + NavigatorState? state; + if (navigatorKey.currentState?.canPop() ?? false) { + state = navigatorKey.currentState; + } + RouteMatchBase walker = currentConfiguration.matches.last; + while (walker is ShellRouteMatch) { + if (walker.navigatorKey.currentState?.canPop() ?? false) { + state = walker.navigatorKey.currentState; } + walker = walker.matches.last; + } + if (state == null) { + throw GoError('There is nothing to pop'); } - throw GoError('There is nothing to pop'); + state.pop(result); } void _debugAssertMatchListNotEmpty() { @@ -103,14 +120,13 @@ class GoRouterDelegate extends RouterDelegate } bool _handlePopPageWithRouteMatch( - Route route, Object? result, RouteMatch? match) { + Route route, Object? result, RouteMatchBase match) { if (route.willHandlePopInternally) { final bool popped = route.didPop(result); assert(!popped); return popped; } - assert(match != null); - final RouteBase routeBase = match!.route; + final RouteBase routeBase = match.route; if (routeBase is! GoRoute || routeBase.onExit == null) { route.didPop(result); _completeRouteMatch(result, match); @@ -130,7 +146,7 @@ class GoRouterDelegate extends RouterDelegate return false; } - void _completeRouteMatch(Object? result, RouteMatch match) { + void _completeRouteMatch(Object? result, RouteMatchBase match) { if (match is ImperativeRouteMatch) { match.complete(result); } @@ -173,25 +189,41 @@ class GoRouterDelegate extends RouterDelegate final BuildContext? navigatorContext = navigatorKey.currentContext; // If navigator is not built or disposed, the GoRoute.onExit is irrelevant. if (navigatorContext != null) { + final List currentGoRouteMatches = []; + currentConfiguration.visitRouteMatches((RouteMatchBase match) { + if (match is RouteMatch) { + currentGoRouteMatches.add(match); + } + return true; + }); + final List newGoRouteMatches = []; + configuration.visitRouteMatches((RouteMatchBase match) { + if (match is RouteMatch) { + newGoRouteMatches.add(match); + } + return true; + }); + final int compareUntil = math.min( - currentConfiguration.matches.length, - configuration.matches.length, + currentGoRouteMatches.length, + newGoRouteMatches.length, ); int indexOfFirstDiff = 0; for (; indexOfFirstDiff < compareUntil; indexOfFirstDiff++) { - if (currentConfiguration.matches[indexOfFirstDiff] != - configuration.matches[indexOfFirstDiff]) { + if (currentGoRouteMatches[indexOfFirstDiff] != + newGoRouteMatches[indexOfFirstDiff]) { break; } } - if (indexOfFirstDiff < currentConfiguration.matches.length) { - final List exitingGoRoutes = currentConfiguration.matches + + if (indexOfFirstDiff < currentGoRouteMatches.length) { + final List exitingGoRoutes = currentGoRouteMatches .sublist(indexOfFirstDiff) .map((RouteMatch match) => match.route) .whereType() .toList(); return _callOnExitStartsAt(exitingGoRoutes.length - 1, - navigatorContext: navigatorContext, routes: exitingGoRoutes) + context: navigatorContext, routes: exitingGoRoutes) .then((bool exit) { if (!exit) { return SynchronousFuture(null); @@ -209,25 +241,23 @@ class GoRouterDelegate extends RouterDelegate /// The returned future resolves to true if all routes below the index all /// return true. Otherwise, the returned future resolves to false. static Future _callOnExitStartsAt(int index, - {required BuildContext navigatorContext, required List routes}) { + {required BuildContext context, required List routes}) { if (index < 0) { return SynchronousFuture(true); } final GoRoute goRoute = routes[index]; if (goRoute.onExit == null) { - return _callOnExitStartsAt(index - 1, - navigatorContext: navigatorContext, routes: routes); + return _callOnExitStartsAt(index - 1, context: context, routes: routes); } Future handleOnExitResult(bool exit) { if (exit) { - return _callOnExitStartsAt(index - 1, - navigatorContext: navigatorContext, routes: routes); + return _callOnExitStartsAt(index - 1, context: context, routes: routes); } return SynchronousFuture(false); } - final FutureOr exitFuture = goRoute.onExit!(navigatorContext); + final FutureOr exitFuture = goRoute.onExit!(context); if (exitFuture is bool) { return handleOnExitResult(exitFuture); } @@ -240,73 +270,3 @@ class GoRouterDelegate extends RouterDelegate return SynchronousFuture(null); } } - -/// An iterator that iterates through navigators that [GoRouterDelegate] -/// created from the inner to outer. -/// -/// The iterator starts with the navigator that hosts the top-most route. This -/// navigator may not be the inner-most navigator if the top-most route is a -/// pageless route, such as a dialog or bottom sheet. -class _NavigatorStateIterator implements Iterator { - _NavigatorStateIterator(this.matchList, this.root) - : index = matchList.matches.length - 1; - - final RouteMatchList matchList; - int index; - - final NavigatorState root; - @override - late NavigatorState current; - - RouteBase _getRouteAtIndex(int index) => matchList.matches[index].route; - - void _findsNextIndex() { - final GlobalKey? parentNavigatorKey = - _getRouteAtIndex(index).parentNavigatorKey; - if (parentNavigatorKey == null) { - index -= 1; - return; - } - - for (index -= 1; index >= 0; index -= 1) { - final RouteBase route = _getRouteAtIndex(index); - if (route is ShellRouteBase) { - if (route.navigatorKeyForSubRoute(_getRouteAtIndex(index + 1)) == - parentNavigatorKey) { - return; - } - } - } - assert(root == parentNavigatorKey.currentState); - } - - @override - bool moveNext() { - if (index < 0) { - return false; - } - _findsNextIndex(); - - while (index >= 0) { - final RouteBase route = _getRouteAtIndex(index); - if (route is ShellRouteBase) { - final GlobalKey navigatorKey = - route.navigatorKeyForSubRoute(_getRouteAtIndex(index + 1)); - // Must have a ModalRoute parent because the navigator ShellRoute - // created must not be the root navigator. - final ModalRoute parentModalRoute = - ModalRoute.of(navigatorKey.currentContext!)!; - // There may be pageless route on top of ModalRoute that the - // parentNavigatorKey is in. For example an open dialog. - if (parentModalRoute.isCurrent) { - current = navigatorKey.currentState!; - return true; - } - } - _findsNextIndex(); - } - assert(index == -1); - current = root; - return true; - } -} diff --git a/packages/go_router/lib/src/match.dart b/packages/go_router/lib/src/match.dart index 3b7a94688692..fd8361026f68 100644 --- a/packages/go_router/lib/src/match.dart +++ b/packages/go_router/lib/src/match.dart @@ -16,77 +16,286 @@ import 'logging.dart'; import 'misc/errors.dart'; import 'path_utils.dart'; import 'route.dart'; +import 'state.dart'; -/// An matched result by matching a [RouteBase] against a location. +/// The function signature for [RouteMatchList.visitRouteMatches] /// -/// This is typically created by calling [RouteMatch.match]. -@immutable -class RouteMatch { - /// Constructor for [RouteMatch]. - const RouteMatch({ - required this.route, - required this.matchedLocation, - required this.pageKey, - }); +/// Return false to stop the walk. +typedef RouteMatchVisitor = bool Function(RouteMatchBase); + +/// The base class for various route matches. +abstract class RouteMatchBase with Diagnosticable { + /// An abstract route match base + const RouteMatchBase(); + + /// The matched route. + RouteBase get route; + + /// The page key. + ValueKey get pageKey; - /// Generate a [RouteMatch] object by matching the `route` with - /// `remainingLocation`. + /// The location string that matches the [route]. + /// + /// for example: + /// + /// uri = '/family/f2/person/p2' + /// route = GoRoute('/family/:id') + /// + /// matchedLocation = '/family/f2' + String get matchedLocation; + + /// Gets the state that represent this route match. + GoRouterState buildState( + RouteConfiguration configuration, RouteMatchList matches); + + /// Generates a list of [RouteMatchBase] objects by matching the `route` and + /// its sub-routes with `uri`. + /// + /// This method returns empty list if it can't find a complete match in the + /// `route`. + /// + /// The `rootNavigatorKey` is required to match routes with + /// parentNavigatorKey. /// /// The extracted path parameters, as the result of the matching, are stored /// into `pathParameters`. - static RouteMatch? match({ + static List match({ + required RouteBase route, + required Map pathParameters, + required GlobalKey rootNavigatorKey, + required Uri uri, + }) { + return _matchByNavigatorKey( + route: route, + matchedPath: '', + remainingLocation: uri.path, + matchedLocation: '', + pathParameters: pathParameters, + scopedNavigatorKey: rootNavigatorKey, + uri: uri, + )[null] ?? + const []; + } + + static const Map?, List> _empty = + ?, List>{}; + + /// Returns a navigator key to route matches maps. + /// + /// The null key corresponds to the route matches of `scopedNavigatorKey`. + /// The scopedNavigatorKey must not be part of the returned map; otherwise, + /// it is impossible to order the matches. + static Map?, List> + _matchByNavigatorKey({ required RouteBase route, required String matchedPath, // e.g. /family/:fid required String remainingLocation, // e.g. person/p1 required String matchedLocation, // e.g. /family/f2 required Map pathParameters, + required GlobalKey scopedNavigatorKey, + required Uri uri, }) { + final Map?, List> result; if (route is ShellRouteBase) { - return RouteMatch( + result = _matchByNavigatorKeyForShellRoute( route: route, - matchedLocation: remainingLocation, - pageKey: ValueKey(route.hashCode.toString()), + matchedPath: matchedPath, + remainingLocation: remainingLocation, + matchedLocation: matchedLocation, + pathParameters: pathParameters, + scopedNavigatorKey: scopedNavigatorKey, + uri: uri, ); } else if (route is GoRoute) { - assert(!route.path.contains('//')); + result = _matchByNavigatorKeyForGoRoute( + route: route, + matchedPath: matchedPath, + remainingLocation: remainingLocation, + matchedLocation: matchedLocation, + pathParameters: pathParameters, + scopedNavigatorKey: scopedNavigatorKey, + uri: uri, + ); + } else { + assert(false, 'Unexpected route type: $route'); + return _empty; + } + // Grab the route matches for the scope navigator key and put it into the + // matches for `null`. + if (result.containsKey(scopedNavigatorKey)) { + final List matchesForScopedNavigator = + result.remove(scopedNavigatorKey)!; + assert(matchesForScopedNavigator.isNotEmpty); + result + .putIfAbsent(null, () => []) + .addAll(matchesForScopedNavigator); + } + return result; + } - final RegExpMatch? match = route.matchPatternAsPrefix(remainingLocation); - if (match == null) { - return null; + static Map?, List> + _matchByNavigatorKeyForShellRoute({ + required ShellRouteBase route, + required String matchedPath, // e.g. /family/:fid + required String remainingLocation, // e.g. person/p1 + required String matchedLocation, // e.g. /family/f2 + required Map pathParameters, + required GlobalKey scopedNavigatorKey, + required Uri uri, + }) { + final GlobalKey? parentKey = + route.parentNavigatorKey == scopedNavigatorKey + ? null + : route.parentNavigatorKey; + Map?, List>? subRouteMatches; + late GlobalKey navigatorKeyUsed; + for (final RouteBase subRoute in route.routes) { + navigatorKeyUsed = route.navigatorKeyForSubRoute(subRoute); + subRouteMatches = _matchByNavigatorKey( + route: subRoute, + matchedPath: matchedPath, + remainingLocation: remainingLocation, + matchedLocation: matchedLocation, + pathParameters: pathParameters, + uri: uri, + scopedNavigatorKey: navigatorKeyUsed, + ); + assert(!subRouteMatches + .containsKey(route.navigatorKeyForSubRoute(subRoute))); + if (subRouteMatches.isNotEmpty) { + break; } + } + if (subRouteMatches?.isEmpty ?? true) { + return _empty; + } + final RouteMatchBase result = ShellRouteMatch( + route: route, + // The RouteConfiguration should have asserted the subRouteMatches must + // have at least one match for this ShellRouteBase. + matches: subRouteMatches!.remove(null)!, + matchedLocation: remainingLocation, + pageKey: ValueKey(route.hashCode.toString()), + navigatorKey: navigatorKeyUsed, + ); + subRouteMatches.putIfAbsent(parentKey, () => []).insert( + 0, + result, + ); - final Map encodedParams = route.extractPathParams(match); - for (final MapEntry param in encodedParams.entries) { - pathParameters[param.key] = Uri.decodeComponent(param.value); - } - final String pathLoc = patternToPath(route.path, encodedParams); - final String newMatchedLocation = - concatenatePaths(matchedLocation, pathLoc); - final String newMatchedPath = concatenatePaths(matchedPath, route.path); - return RouteMatch( - route: route, + return subRouteMatches; + } + + static Map?, List> + _matchByNavigatorKeyForGoRoute({ + required GoRoute route, + required String matchedPath, // e.g. /family/:fid + required String remainingLocation, // e.g. person/p1 + required String matchedLocation, // e.g. /family/f2 + required Map pathParameters, + required GlobalKey scopedNavigatorKey, + required Uri uri, + }) { + final GlobalKey? parentKey = + route.parentNavigatorKey == scopedNavigatorKey + ? null + : route.parentNavigatorKey; + + final RegExpMatch? regExpMatch = + route.matchPatternAsPrefix(remainingLocation); + if (regExpMatch == null) { + return _empty; + } + final Map encodedParams = + route.extractPathParams(regExpMatch); + // A temporary map to hold path parameters. This map is merged into + // pathParameters only when this route is part of the returned result. + final Map currentPathParameter = + encodedParams.map((String key, String value) => + MapEntry(key, Uri.decodeComponent(value))); + final String pathLoc = patternToPath(route.path, encodedParams); + final String newMatchedLocation = + concatenatePaths(matchedLocation, pathLoc); + final String newMatchedPath = concatenatePaths(matchedPath, route.path); + if (newMatchedLocation.toLowerCase() == uri.path.toLowerCase()) { + // A complete match. + pathParameters.addAll(currentPathParameter); + + return ?, List>{ + parentKey: [ + RouteMatch( + route: route, + matchedLocation: newMatchedLocation, + pageKey: ValueKey(newMatchedPath), + ), + ], + }; + } + assert(uri.path.startsWith(newMatchedLocation)); + assert(remainingLocation.isNotEmpty); + + final String childRestLoc = uri.path.substring( + newMatchedLocation.length + (newMatchedLocation == '/' ? 0 : 1)); + + Map?, List>? subRouteMatches; + for (final RouteBase subRoute in route.routes) { + subRouteMatches = _matchByNavigatorKey( + route: subRoute, + matchedPath: newMatchedPath, + remainingLocation: childRestLoc, matchedLocation: newMatchedLocation, - pageKey: ValueKey(newMatchedPath), + pathParameters: pathParameters, + uri: uri, + scopedNavigatorKey: scopedNavigatorKey, ); + if (subRouteMatches.isNotEmpty) { + break; + } + } + if (subRouteMatches?.isEmpty ?? true) { + // If not finding a sub route match, it is considered not matched for this + // route even if this route match part of the `remainingLocation`. + return _empty; } - assert(false, 'Unexpected route type: $route'); - return null; + + pathParameters.addAll(currentPathParameter); + subRouteMatches!.putIfAbsent(parentKey, () => []).insert( + 0, + RouteMatch( + route: route, + matchedLocation: newMatchedLocation, + pageKey: ValueKey(newMatchedPath), + )); + return subRouteMatches; } + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('route', route)); + } +} + +/// An matched result by matching a [GoRoute] against a location. +/// +/// This is typically created by calling [RouteMatchBase.match]. +@immutable +class RouteMatch extends RouteMatchBase { + /// Constructor for [RouteMatch]. + const RouteMatch({ + required this.route, + required this.matchedLocation, + required this.pageKey, + }); + /// The matched route. - final RouteBase route; + @override + final GoRoute route; - /// The location string that matches the [route]. - /// - /// for example: - /// - /// uri = '/family/f2/person/p2' - /// route = GoRoute('/family/:id') - /// - /// matchedLocation = '/family/f2' + @override final String matchedLocation; - /// Value key of type string, to hold a unique reference to a page. + @override final ValueKey pageKey; @override @@ -102,6 +311,109 @@ class RouteMatch { @override int get hashCode => Object.hash(route, matchedLocation, pageKey); + + @override + GoRouterState buildState( + RouteConfiguration configuration, RouteMatchList matches) { + return GoRouterState( + configuration, + uri: matches.uri, + matchedLocation: matchedLocation, + fullPath: matches.fullPath, + pathParameters: matches.pathParameters, + pageKey: pageKey, + name: route.name, + path: route.path, + extra: matches.extra, + ); + } +} + +/// An matched result by matching a [ShellRoute] against a location. +/// +/// This is typically created by calling [RouteMatchBase.match]. +@immutable +class ShellRouteMatch extends RouteMatchBase { + /// Create a match. + ShellRouteMatch({ + required this.route, + required this.matches, + required this.matchedLocation, + required this.pageKey, + required this.navigatorKey, + }) : assert(matches.isNotEmpty); + + @override + final ShellRouteBase route; + + RouteMatch get _lastLeaf { + RouteMatchBase currentMatch = matches.last; + while (currentMatch is ShellRouteMatch) { + currentMatch = currentMatch.matches.last; + } + return currentMatch as RouteMatch; + } + + /// The navigator key used for this match. + final GlobalKey navigatorKey; + + @override + final String matchedLocation; + + /// The matches that will be built under this shell route. + final List matches; + + @override + final ValueKey pageKey; + + @override + GoRouterState buildState( + RouteConfiguration configuration, RouteMatchList matches) { + // The route related data is stored in the leaf route match. + final RouteMatch leafMatch = _lastLeaf; + if (leafMatch is ImperativeRouteMatch) { + matches = leafMatch.matches; + } + return GoRouterState( + configuration, + uri: matches.uri, + matchedLocation: matchedLocation, + fullPath: matches.fullPath, + pathParameters: matches.pathParameters, + pageKey: pageKey, + extra: matches.extra, + ); + } + + /// Creates a new shell route match with the given matches. + /// + /// This is typically used when pushing or popping [RouteMatchBase] from + /// [RouteMatchList]. + @internal + ShellRouteMatch copyWith({ + required List? matches, + }) { + return ShellRouteMatch( + matches: matches ?? this.matches, + route: route, + matchedLocation: matchedLocation, + pageKey: pageKey, + navigatorKey: navigatorKey, + ); + } + + @override + bool operator ==(Object other) { + return other is ShellRouteMatch && + route == other.route && + matchedLocation == other.matchedLocation && + const ListEquality().equals(matches, other.matches) && + pageKey == other.pageKey; + } + + @override + int get hashCode => + Object.hash(route, matchedLocation, Object.hashAll(matches), pageKey); } /// The route match that represent route pushed through [GoRouter.push]. @@ -113,7 +425,8 @@ class ImperativeRouteMatch extends RouteMatch { route: _getsLastRouteFromMatches(matches), matchedLocation: _getsMatchedLocationFromMatches(matches), ); - static RouteBase _getsLastRouteFromMatches(RouteMatchList matchList) { + + static GoRoute _getsLastRouteFromMatches(RouteMatchList matchList) { if (matchList.isError) { return GoRoute( path: 'error', builder: (_, __) => throw UnimplementedError()); @@ -140,6 +453,12 @@ class ImperativeRouteMatch extends RouteMatch { completer.complete(value); } + @override + GoRouterState buildState( + RouteConfiguration configuration, RouteMatchList matches) { + return super.buildState(configuration, this.matches); + } + @override bool operator ==(Object other) { return other is ImperativeRouteMatch && @@ -152,11 +471,13 @@ class ImperativeRouteMatch extends RouteMatch { int get hashCode => Object.hash(super.hashCode, completer, matches.hashCode); } -/// The list of [RouteMatch] objects. +/// The list of [RouteMatchBase] objects. +/// +/// This can contains tree structure if there are [ShellRouteMatch] in the list. /// /// This corresponds to the GoRouter's history. @immutable -class RouteMatchList { +class RouteMatchList with Diagnosticable { /// RouteMatchList constructor. RouteMatchList({ required this.matches, @@ -168,12 +489,12 @@ class RouteMatchList { /// Constructs an empty matches object. static RouteMatchList empty = RouteMatchList( - matches: const [], + matches: const [], uri: Uri(), pathParameters: const {}); /// The route matches. - final List matches; + final List matches; /// Parameters for the matched route, URI-encoded. /// @@ -231,19 +552,25 @@ class RouteMatchList { /// ```dart /// [RouteMatchA(), RouteMatchB(), RouteMatchC()] /// ``` - static String _generateFullPath(Iterable matches) { + static String _generateFullPath(Iterable matches) { final StringBuffer buffer = StringBuffer(); bool addsSlash = false; - for (final RouteMatch match in matches - .where((RouteMatch match) => match is! ImperativeRouteMatch)) { - final RouteBase route = match.route; - if (route is GoRoute) { - if (addsSlash) { - buffer.write('/'); - } - buffer.write(route.path); - addsSlash = addsSlash || route.path != '/'; + for (final RouteMatchBase match in matches + .where((RouteMatchBase match) => match is! ImperativeRouteMatch)) { + if (addsSlash) { + buffer.write('/'); + } + final String pathSegment; + if (match is RouteMatch) { + pathSegment = match.route.path; + } else if (match is ShellRouteMatch) { + pathSegment = _generateFullPath(match.matches); + } else { + assert(false, 'Unexpected match type: $match'); + continue; } + buffer.write(pathSegment); + addsSlash = pathSegment.isNotEmpty && (addsSlash || pathSegment != '/'); } return buffer.toString(); } @@ -257,32 +584,74 @@ class RouteMatchList { /// Returns a new instance of RouteMatchList with the input `match` pushed /// onto the current instance. RouteMatchList push(ImperativeRouteMatch match) { - // Imperative route match doesn't change the uri and path parameters. - return _copyWith(matches: [...matches, match]); + if (match.matches.isError) { + return copyWith(matches: [...matches, match]); + } + return copyWith( + matches: _createNewMatchUntilIncompatible( + matches, + match.matches.matches, + match, + ), + ); + } + + static List _createNewMatchUntilIncompatible( + List currentMatches, + List otherMatches, + ImperativeRouteMatch match, + ) { + final List newMatches = currentMatches.toList(); + if (otherMatches.last is ShellRouteMatch && + newMatches.isNotEmpty && + otherMatches.last.route == newMatches.last.route) { + assert(newMatches.last is ShellRouteMatch); + final ShellRouteMatch lastShellRouteMatch = + newMatches.removeLast() as ShellRouteMatch; + newMatches.add( + // Create a new copy of the `lastShellRouteMatch`. + lastShellRouteMatch.copyWith( + matches: _createNewMatchUntilIncompatible(lastShellRouteMatch.matches, + (otherMatches.last as ShellRouteMatch).matches, match), + ), + ); + return newMatches; + } + newMatches + .add(_cloneBranchAndInsertImperativeMatch(otherMatches.last, match)); + return newMatches; + } + + static RouteMatchBase _cloneBranchAndInsertImperativeMatch( + RouteMatchBase branch, ImperativeRouteMatch match) { + if (branch is ShellRouteMatch) { + return branch.copyWith( + matches: [ + _cloneBranchAndInsertImperativeMatch(branch.matches.last, match), + ], + ); + } + // Add the input `match` instead of the incompatibleMatch since it contains + // page key and push future. + assert(branch.route == match.route); + return match; } /// Returns a new instance of RouteMatchList with the input `match` removed /// from the current instance. - RouteMatchList remove(RouteMatch match) { - final List newMatches = matches.toList(); - final int index = newMatches.indexOf(match); - assert(index != -1); - newMatches.removeRange(index, newMatches.length); - - // Also pop ShellRoutes that have no subsequent route matches and GoRoutes - // that only have redirect. - while (newMatches.isNotEmpty && - (newMatches.last.route is ShellRouteBase || - (newMatches.last.route as GoRoute).redirectOnly)) { - newMatches.removeLast(); - } - // Removing ImperativeRouteMatch should not change uri and pathParameters. - if (match is ImperativeRouteMatch) { - return _copyWith(matches: newMatches); + RouteMatchList remove(RouteMatchBase match) { + final List newMatches = + _removeRouteMatchFromList(matches, match); + if (newMatches == matches) { + return this; } - final String fullPath = _generateFullPath( - newMatches.where((RouteMatch match) => match is! ImperativeRouteMatch)); + final String fullPath = _generateFullPath(newMatches); + if (this.fullPath == fullPath) { + return copyWith( + matches: newMatches, + ); + } // Need to remove path parameters that are no longer in the fullPath. final List newParameters = []; patternToRegExp(fullPath, newParameters); @@ -294,24 +663,110 @@ class RouteMatchList { ); final Uri newUri = uri.replace(path: patternToPath(fullPath, newPathParameters)); - return _copyWith( + return copyWith( matches: newMatches, uri: newUri, pathParameters: newPathParameters, ); } - /// The last matching route. - RouteMatch get last => matches.last; + /// Returns a new List from the input matches with target removed. + /// + /// This method recursively looks into any ShellRouteMatch in matches and + /// removes target if it found a match in the match list nested in + /// ShellRouteMatch. + /// + /// This method returns a new list as long as the target is found in the + /// matches' subtree. + /// + /// If a target is found, the target and every node after the target in tree + /// order is removed. + static List _removeRouteMatchFromList( + List matches, RouteMatchBase target) { + // Remove is caused by pop; therefore, start searching from the end. + for (int index = matches.length - 1; index >= 0; index -= 1) { + final RouteMatchBase match = matches[index]; + if (match == target) { + // Remove any redirect only route immediately before the target. + while (index > 0) { + final RouteMatchBase lookBefore = matches[index - 1]; + if (lookBefore is! RouteMatch || !lookBefore.route.redirectOnly) { + break; + } + index -= 1; + } + return matches.sublist(0, index); + } + if (match is ShellRouteMatch) { + final List newSubMatches = + _removeRouteMatchFromList(match.matches, target); + if (newSubMatches == match.matches) { + // Didn't find target in the newSubMatches. + continue; + } + // Removes `match` if its sub match list become empty after the remove. + return [ + ...matches.sublist(0, index), + if (newSubMatches.isNotEmpty) match.copyWith(matches: newSubMatches), + ]; + } + } + // Target is not in the match subtree. + return matches; + } + + /// The last leaf route. + /// + /// If the last RouteMatchBase from [matches] is a ShellRouteMatch, it + /// recursively goes into its [ShellRouteMatch.matches] until it reach the leaf + /// [RouteMatch]. + RouteMatch get last { + if (matches.last is RouteMatch) { + return matches.last as RouteMatch; + } + return (matches.last as ShellRouteMatch)._lastLeaf; + } /// Returns true if the current match intends to display an error screen. bool get isError => error != null; /// The routes for each of the matches. - List get routes => matches.map((RouteMatch e) => e.route).toList(); + List get routes { + final List result = []; + visitRouteMatches((RouteMatchBase match) { + result.add(match.route); + return true; + }); + return result; + } + + /// Traverse route matches in this match list in preorder until visitor + /// returns false. + /// + /// This method visit recursively into shell route matches. + @internal + void visitRouteMatches(RouteMatchVisitor visitor) { + _visitRouteMatches(matches, visitor); + } + + static bool _visitRouteMatches( + List matches, RouteMatchVisitor visitor) { + for (final RouteMatchBase routeMatch in matches) { + if (!visitor(routeMatch)) { + return false; + } + if (routeMatch is ShellRouteMatch && + !_visitRouteMatches(routeMatch.matches, visitor)) { + return false; + } + } + return true; + } - RouteMatchList _copyWith({ - List? matches, + /// Create a new [RouteMatchList] with given parameter replaced. + @internal + RouteMatchList copyWith({ + List? matches, Uri? uri, Map? pathParameters, }) { @@ -332,7 +787,7 @@ class RouteMatchList { uri == other.uri && extra == other.extra && error == other.error && - const ListEquality().equals(matches, other.matches) && + const ListEquality().equals(matches, other.matches) && const MapEquality() .equals(pathParameters, other.pathParameters); } @@ -352,8 +807,11 @@ class RouteMatchList { } @override - String toString() { - return '${objectRuntimeType(this, 'RouteMatchList')}($fullPath)'; + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('uri', uri)); + properties + .add(DiagnosticsProperty>('matches', matches)); } } @@ -391,15 +849,23 @@ class _RouteMatchListEncoder final RouteConfiguration configuration; @override Map convert(RouteMatchList input) { - final List> imperativeMatches = input.matches - .whereType() - .map((ImperativeRouteMatch e) => _toPrimitives( - e.matches.uri.toString(), e.matches.extra, - pageKey: e.pageKey.value)) - .toList(); + final List imperativeMatches = + []; + input.visitRouteMatches((RouteMatchBase match) { + if (match is ImperativeRouteMatch) { + imperativeMatches.add(match); + } + return true; + }); + final List> encodedImperativeMatches = + imperativeMatches + .map((ImperativeRouteMatch e) => _toPrimitives( + e.matches.uri.toString(), e.matches.extra, + pageKey: e.pageKey.value)) + .toList(); return _toPrimitives(input.uri.toString(), input.extra, - imperativeMatches: imperativeMatches); + imperativeMatches: encodedImperativeMatches); } Map _toPrimitives(String location, Object? extra, diff --git a/packages/go_router/lib/src/pages/cupertino.dart b/packages/go_router/lib/src/pages/cupertino.dart index 06a04ccf4e40..cf64d8124486 100644 --- a/packages/go_router/lib/src/pages/cupertino.dart +++ b/packages/go_router/lib/src/pages/cupertino.dart @@ -8,8 +8,8 @@ import 'package:flutter/cupertino.dart'; import '../misc/extensions.dart'; /// Checks for CupertinoApp in the widget tree. -bool isCupertinoApp(Element elem) => - elem.findAncestorWidgetOfExactType() != null; +bool isCupertinoApp(BuildContext context) => + context.findAncestorWidgetOfExactType() != null; /// Creates a Cupertino HeroController. HeroController createCupertinoHeroController() => diff --git a/packages/go_router/lib/src/pages/material.dart b/packages/go_router/lib/src/pages/material.dart index fe0f7974d58b..06fb770a78ee 100644 --- a/packages/go_router/lib/src/pages/material.dart +++ b/packages/go_router/lib/src/pages/material.dart @@ -9,8 +9,8 @@ import 'package:flutter/material.dart'; import '../misc/extensions.dart'; /// Checks for MaterialApp in the widget tree. -bool isMaterialApp(Element elem) => - elem.findAncestorWidgetOfExactType() != null; +bool isMaterialApp(BuildContext context) => + context.findAncestorWidgetOfExactType() != null; /// Creates a Material HeroController. HeroController createMaterialHeroController() => diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index dc70baa16b2b..2361470fdeaf 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -12,7 +12,6 @@ import 'configuration.dart'; import 'information_provider.dart'; import 'logging.dart'; import 'match.dart'; -import 'route.dart'; import 'router.dart'; /// The function signature of [GoRouteInformationParser.onParserException]. @@ -105,7 +104,7 @@ class GoRouteInformationParser extends RouteInformationParser { assert(() { if (matchList.isNotEmpty) { - assert(!(matchList.last.route as GoRoute).redirectOnly, + assert(!matchList.last.route.redirectOnly, 'A redirect-only route must redirect to location different from itself.\n The offending route: ${matchList.last.route}'); } return true; diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index 587905a0879b..2fa4bd04640a 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -493,18 +493,6 @@ abstract class ShellRouteBase extends RouteBase { /// Returns the key for the [Navigator] that is to be used for the specified /// immediate sub-route of this shell route. GlobalKey navigatorKeyForSubRoute(RouteBase subRoute); - - /// Returns the keys for the [Navigator]s associated with this shell route. - Iterable> get _navigatorKeys => - >[]; - - /// Returns all the Navigator keys of this shell route as well as those of any - /// descendant shell routes. - Iterable> _navigatorKeysRecursively() { - return RouteBase.routesRecursively([this]) - .whereType() - .expand((ShellRouteBase e) => e._navigatorKeys); - } } /// Context object used when building the shell and Navigator for a shell route. @@ -710,10 +698,6 @@ class ShellRoute extends ShellRouteBase { return navigatorKey; } - @override - Iterable> get _navigatorKeys => - >[navigatorKey]; - @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); @@ -920,7 +904,6 @@ class StatefulShellRoute extends ShellRouteBase { return branch!.navigatorKey; } - @override Iterable> get _navigatorKeys => branches.map((StatefulShellBranch b) => b.navigatorKey); @@ -1222,26 +1205,24 @@ class StatefulNavigationShellState extends State /// trailing imperative matches from the RouterMatchList that are targeted at /// any other (often top-level) Navigator. RouteMatchList _scopedMatchList(RouteMatchList matchList) { - final Iterable> validKeys = - route._navigatorKeysRecursively(); - final int index = matchList.matches.indexWhere((RouteMatch e) { - final RouteBase route = e.route; - if (e is ImperativeRouteMatch && route is GoRoute) { - return route.parentNavigatorKey != null && - !validKeys.contains(route.parentNavigatorKey); + return matchList.copyWith(matches: _scopeMatches(matchList.matches)); + } + + List _scopeMatches(List matches) { + final List result = []; + for (final RouteMatchBase match in matches) { + if (match is ShellRouteMatch) { + if (match.route == route) { + result.add(match); + // Discard any other route match after current shell route. + break; + } + result.add(match.copyWith(matches: _scopeMatches(match.matches))); + continue; } - return false; - }); - if (index > 0) { - final List matches = matchList.matches.sublist(0, index); - return RouteMatchList( - extra: matchList.extra, - matches: matches, - uri: Uri.parse(matches.last.matchedLocation), - pathParameters: matchList.pathParameters, - ); + result.add(match); } - return matchList; + return result; } void _updateCurrentBranchStateFromWidget() { diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index 42e8e8e7ba62..3e91cd898024 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: 12.1.3 +version: 13.0.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/builder_test.dart b/packages/go_router/test/builder_test.dart index f1a08eef4269..f03c090a7b44 100644 --- a/packages/go_router/test/builder_test.dart +++ b/packages/go_router/test/builder_test.dart @@ -49,21 +49,24 @@ void main() { }); testWidgets('Builds ShellRoute', (WidgetTester tester) async { + final GlobalKey shellNavigatorKey = + GlobalKey(); final RouteConfiguration config = createRouteConfiguration( routes: [ ShellRoute( - builder: - (BuildContext context, GoRouterState state, Widget child) { - return _DetailsScreen(); - }, - routes: [ - GoRoute( - path: '/', - builder: (BuildContext context, GoRouterState state) { - return _DetailsScreen(); - }, - ), - ]), + navigatorKey: shellNavigatorKey, + builder: (BuildContext context, GoRouterState state, Widget child) { + return _DetailsScreen(); + }, + routes: [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) { + return _DetailsScreen(); + }, + ), + ], + ), ], redirectLimit: 10, topRedirect: (BuildContext context, GoRouterState state) { @@ -73,9 +76,20 @@ void main() { ); final RouteMatchList matches = RouteMatchList( - matches: [ - createRouteMatch(config.routes.first, '/'), - createRouteMatch(config.routes.first.routes.first, '/'), + matches: [ + ShellRouteMatch( + route: config.routes.first as ShellRouteBase, + matchedLocation: '', + pageKey: const ValueKey(''), + navigatorKey: shellNavigatorKey, + matches: [ + RouteMatch( + route: config.routes.first.routes.first as GoRoute, + matchedLocation: '/', + pageKey: const ValueKey('/'), + ), + ], + ), ], uri: Uri.parse('/'), pathParameters: const {}, @@ -164,17 +178,19 @@ void main() { ); final RouteMatchList matches = RouteMatchList( - matches: [ - RouteMatch( - route: config.routes.first, - matchedLocation: '', - pageKey: const ValueKey(''), - ), - RouteMatch( - route: config.routes.first.routes.first, - matchedLocation: '/details', - pageKey: const ValueKey('/details'), - ), + matches: [ + ShellRouteMatch( + route: config.routes.first as ShellRouteBase, + matchedLocation: '', + pageKey: const ValueKey(''), + navigatorKey: shellNavigatorKey, + matches: [ + RouteMatch( + route: config.routes.first.routes.first as GoRoute, + matchedLocation: '/details', + pageKey: const ValueKey('/details'), + ), + ]), ], uri: Uri.parse('/details'), pathParameters: const {}); @@ -290,9 +306,20 @@ void main() { ); final RouteMatchList matches = RouteMatchList( - matches: [ - createRouteMatch(config.routes.first, ''), - createRouteMatch(config.routes.first.routes.first, '/a'), + matches: [ + ShellRouteMatch( + route: config.routes.first as ShellRouteBase, + matchedLocation: '', + pageKey: const ValueKey(''), + navigatorKey: shellNavigatorKey, + matches: [ + RouteMatch( + route: config.routes.first.routes.first as GoRoute, + matchedLocation: '/a', + pageKey: const ValueKey('/a'), + ), + ], + ), ], uri: Uri.parse('/b'), pathParameters: const {}, @@ -430,8 +457,7 @@ class _BuilderTestWidget extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - home: builder.tryBuild(context, matches, false, - routeConfiguration.navigatorKey, , GoRouterState>{}), + home: builder.build(context, matches, false), ); } } diff --git a/packages/go_router/test/delegate_test.dart b/packages/go_router/test/delegate_test.dart index c1ece69a465c..0feca449fc98 100644 --- a/packages/go_router/test/delegate_test.dart +++ b/packages/go_router/test/delegate_test.dart @@ -77,8 +77,8 @@ void main() { final GoRouter goRouter = await createGoRouter(tester) ..push('/error'); await tester.pumpAndSettle(); - - final RouteMatch last = + expect(find.byType(ErrorScreen), findsOneWidget); + final RouteMatchBase last = goRouter.routerDelegate.currentConfiguration.matches.last; await goRouter.routerDelegate.popRoute(); expect(goRouter.routerDelegate.currentConfiguration.matches.length, 1); @@ -194,10 +194,8 @@ void main() { await createGoRouterWithStatefulShellRoute(tester); goRouter.push('/c/c1'); await tester.pumpAndSettle(); - goRouter.push('/a'); await tester.pumpAndSettle(); - expect(goRouter.routerDelegate.currentConfiguration.matches.length, 3); expect( goRouter.routerDelegate.currentConfiguration.matches[1].pageKey, @@ -219,11 +217,13 @@ void main() { goRouter.push('/c/c2'); await tester.pumpAndSettle(); - expect(goRouter.routerDelegate.currentConfiguration.matches.length, 3); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 2); + final ShellRouteMatch shellRouteMatch = goRouter.routerDelegate + .currentConfiguration.matches.last as ShellRouteMatch; + expect(shellRouteMatch.matches.length, 2); expect( - goRouter.routerDelegate.currentConfiguration.matches[1].pageKey, - isNot(equals( - goRouter.routerDelegate.currentConfiguration.matches[2].pageKey)), + shellRouteMatch.matches[0].pageKey, + isNot(equals(shellRouteMatch.matches[1].pageKey)), ); }, ); @@ -240,11 +240,13 @@ void main() { goRouter.push('/c'); await tester.pumpAndSettle(); - expect(goRouter.routerDelegate.currentConfiguration.matches.length, 3); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 2); + final ShellRouteMatch shellRouteMatch = goRouter.routerDelegate + .currentConfiguration.matches.last as ShellRouteMatch; + expect(shellRouteMatch.matches.length, 2); expect( - goRouter.routerDelegate.currentConfiguration.matches[1].pageKey, - isNot(equals( - goRouter.routerDelegate.currentConfiguration.matches[2].pageKey)), + shellRouteMatch.matches[0].pageKey, + isNot(equals(shellRouteMatch.matches[1].pageKey)), ); }, ); @@ -294,7 +296,7 @@ void main() { goRouter.push('/page-0'); goRouter.routerDelegate.addListener(expectAsync0(() {})); - final RouteMatch first = + final RouteMatchBase first = goRouter.routerDelegate.currentConfiguration.matches.first; final RouteMatch last = goRouter.routerDelegate.currentConfiguration.last; goRouter.pushReplacement('/page-1'); @@ -376,7 +378,7 @@ void main() { goRouter.pushNamed('page0'); goRouter.routerDelegate.addListener(expectAsync0(() {})); - final RouteMatch first = + final RouteMatchBase first = goRouter.routerDelegate.currentConfiguration.matches.first; final RouteMatch last = goRouter.routerDelegate.currentConfiguration.last; @@ -395,7 +397,7 @@ void main() { expect( goRouter.routerDelegate.currentConfiguration.last, isA().having( - (RouteMatch match) => (match.route as GoRoute).name, + (RouteMatch match) => match.route.name, 'match.route.name', 'page1', ), @@ -425,7 +427,7 @@ void main() { goRouter.push('/page-0'); goRouter.routerDelegate.addListener(expectAsync0(() {})); - final RouteMatch first = + final RouteMatchBase first = goRouter.routerDelegate.currentConfiguration.matches.first; final RouteMatch last = goRouter.routerDelegate.currentConfiguration.last; goRouter.replace('/page-1'); @@ -546,7 +548,7 @@ void main() { goRouter.pushNamed('page0'); goRouter.routerDelegate.addListener(expectAsync0(() {})); - final RouteMatch first = + final RouteMatchBase first = goRouter.routerDelegate.currentConfiguration.matches.first; final RouteMatch last = goRouter.routerDelegate.currentConfiguration.last; goRouter.replaceNamed('page1'); diff --git a/packages/go_router/test/go_router_test.dart b/packages/go_router/test/go_router_test.dart index e338aeee65e7..9983caafa6c6 100644 --- a/packages/go_router/test/go_router_test.dart +++ b/packages/go_router/test/go_router_test.dart @@ -61,7 +61,7 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/'); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expect((matches.first.route as GoRoute).name, '1'); @@ -135,7 +135,7 @@ void main() { ); router.go('/foo'); await tester.pumpAndSettle(); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(0)); expect(find.byType(TestErrorScreen), findsOneWidget); @@ -156,7 +156,7 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/login'); await tester.pumpAndSettle(); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expect(matches.first.matchedLocation, '/login'); @@ -186,7 +186,7 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/login'); await tester.pumpAndSettle(); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expect(matches.first.matchedLocation, '/login'); @@ -211,7 +211,7 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/login/'); await tester.pumpAndSettle(); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expect(matches.first.matchedLocation, '/login'); @@ -231,7 +231,7 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/profile/'); await tester.pumpAndSettle(); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expect(matches.first.matchedLocation, '/profile/foo'); @@ -251,7 +251,7 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/profile/?bar=baz'); await tester.pumpAndSettle(); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expect(matches.first.matchedLocation, '/profile/foo'); @@ -348,7 +348,7 @@ void main() { router.pop(); await tester.pumpAndSettle(); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches.length, 4); expect(find.byType(HomeScreen), findsNothing); @@ -375,7 +375,7 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/login'); await tester.pumpAndSettle(); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches.length, 2); expect(matches.first.matchedLocation, '/'); @@ -497,7 +497,7 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/bar'); await tester.pumpAndSettle(); - List matches = + List matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(2)); expect(find.byType(Page1Screen), findsOneWidget); @@ -621,7 +621,7 @@ void main() { const String loc = '/FaMiLy/f2'; router.go(loc); await tester.pumpAndSettle(); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; // NOTE: match the lower case, since location is canonicalized to match the @@ -650,7 +650,7 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/user'); await tester.pumpAndSettle(); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expect(find.byType(DummyScreen), findsOneWidget); @@ -908,7 +908,6 @@ void main() { ); expect(find.text(notifier.value), findsOneWidget); - notifier.value = 'updated'; await tester.pump(); expect(find.text(notifier.value), findsOneWidget); @@ -1927,7 +1926,7 @@ void main() { errorBuilder: (BuildContext context, GoRouterState state) => TestErrorScreen(state.error!), ); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(0)); expect(find.byType(TestErrorScreen), findsOneWidget); @@ -1955,7 +1954,7 @@ void main() { TestErrorScreen(state.error!), ); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(0)); expect(find.byType(TestErrorScreen), findsOneWidget); @@ -1980,7 +1979,7 @@ void main() { TestErrorScreen(state.error!), ); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(0)); expect(find.byType(TestErrorScreen), findsOneWidget); @@ -2004,7 +2003,7 @@ void main() { TestErrorScreen(state.error!), ); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(0)); expect(find.byType(TestErrorScreen), findsOneWidget); @@ -2066,7 +2065,7 @@ void main() { }, ); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expect(find.byType(LoginScreen), findsOneWidget); @@ -2101,7 +2100,7 @@ void main() { }, ); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(2)); }); @@ -2130,7 +2129,7 @@ void main() { initialLocation: loc, ); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expect(find.byType(HomeScreen), findsOneWidget); @@ -2172,7 +2171,7 @@ void main() { initialLocation: '/family/f2/person/p1', ); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches.length, 3); expect(find.byType(HomeScreen, skipOffstage: false), findsOneWidget); @@ -2194,7 +2193,7 @@ void main() { redirectLimit: 10, ); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(0)); expect(find.byType(TestErrorScreen), findsOneWidget); @@ -2878,7 +2877,10 @@ void main() { RouteMatchList matches = router.routerDelegate.currentConfiguration; expect(router.routerDelegate.currentConfiguration.uri.toString(), loc); - expect(matches.matches, hasLength(4)); + expect(matches.matches, hasLength(1)); + final ShellRouteMatch shellRouteMatch = + matches.matches.first as ShellRouteMatch; + expect(shellRouteMatch.matches, hasLength(3)); expect(find.byType(PersonScreen), findsOneWidget); expect(matches.pathParameters['fid'], fid); expect(matches.pathParameters['pid'], pid); @@ -2981,7 +2983,7 @@ void main() { 'q2': ['v2', 'v3'], }); await tester.pumpAndSettle(); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); @@ -3033,7 +3035,7 @@ void main() { router.go('/page?q1=v1&q2=v2&q2=v3'); await tester.pumpAndSettle(); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); @@ -3084,7 +3086,7 @@ void main() { router.go('/page?q1=v1&q2=v2&q2=v3'); await tester.pumpAndSettle(); - final List matches = + final List matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); @@ -3974,6 +3976,7 @@ void main() { expect(find.text('Screen A'), findsNothing); expect(find.text('Screen B'), findsOneWidget); + // This push '/common' on top of entire stateful shell route page. router.push('/common', extra: 'X'); await tester.pumpAndSettle(); expect(find.text('Screen A'), findsNothing); @@ -3987,8 +3990,7 @@ void main() { routeState!.goBranch(1); await tester.pumpAndSettle(); expect(find.text('Screen A'), findsNothing); - expect(find.text('Screen B'), findsNothing); - expect(find.text('Common - X'), findsOneWidget); + expect(find.text('Screen B'), findsOneWidget); }); testWidgets( diff --git a/packages/go_router/test/imperative_api_test.dart b/packages/go_router/test/imperative_api_test.dart new file mode 100644 index 000000000000..7091a0383541 --- /dev/null +++ b/packages/go_router/test/imperative_api_test.dart @@ -0,0 +1,297 @@ +// 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:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'test_helpers.dart'; + +void main() { + testWidgets('replace inside shell route', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/134524. + final UniqueKey a = UniqueKey(); + final UniqueKey b = UniqueKey(); + final List routes = [ + ShellRoute( + builder: (_, __, Widget child) { + return Scaffold( + appBar: AppBar(title: const Text('shell')), + body: child, + ); + }, + routes: [ + GoRoute( + path: '/a', + builder: (_, __) => DummyScreen(key: a), + ), + GoRoute( + path: '/b', + builder: (_, __) => DummyScreen(key: b), + ) + ], + ), + ]; + final GoRouter router = + await createRouter(routes, tester, initialLocation: '/a'); + + expect(find.text('shell'), findsOneWidget); + expect(find.byKey(a), findsOneWidget); + + router.replace('/b'); + await tester.pumpAndSettle(); + expect(find.text('shell'), findsOneWidget); + expect(find.byKey(a), findsNothing); + expect(find.byKey(b), findsOneWidget); + }); + + testWidgets('push from outside of shell route', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/130406. + final UniqueKey a = UniqueKey(); + final UniqueKey b = UniqueKey(); + final List routes = [ + GoRoute( + path: '/a', + builder: (_, __) => DummyScreen(key: a), + ), + ShellRoute( + builder: (_, __, Widget child) { + return Scaffold( + appBar: AppBar(title: const Text('shell')), + body: child, + ); + }, + routes: [ + GoRoute( + path: '/b', + builder: (_, __) => DummyScreen(key: b), + ), + ], + ), + ]; + final GoRouter router = + await createRouter(routes, tester, initialLocation: '/a'); + + expect(find.text('shell'), findsNothing); + expect(find.byKey(a), findsOneWidget); + + router.push('/b'); + await tester.pumpAndSettle(); + expect(find.text('shell'), findsOneWidget); + expect(find.byKey(a), findsNothing); + expect(find.byKey(b), findsOneWidget); + }); + + testWidgets('shell route reflect imperative push', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/125752. + final UniqueKey home = UniqueKey(); + final UniqueKey a = UniqueKey(); + final List routes = [ + ShellRoute( + builder: (_, GoRouterState state, Widget child) { + return Scaffold( + appBar: AppBar(title: Text('location: ${state.uri.path}')), + body: child, + ); + }, + routes: [ + GoRoute( + path: '/', + builder: (_, __) => DummyScreen(key: home), + routes: [ + GoRoute( + path: 'a', + builder: (_, __) => DummyScreen(key: a), + ), + ]), + ], + ), + ]; + final GoRouter router = + await createRouter(routes, tester, initialLocation: '/a'); + + expect(find.text('location: /a'), findsOneWidget); + expect(find.byKey(a), findsOneWidget); + + router.pop(); + await tester.pumpAndSettle(); + expect(find.text('location: /'), findsOneWidget); + expect(find.byKey(a), findsNothing); + expect(find.byKey(home), findsOneWidget); + + router.push('/a'); + await tester.pumpAndSettle(); + expect(find.text('location: /a'), findsOneWidget); + expect(find.byKey(a), findsOneWidget); + expect(find.byKey(home), findsNothing); + }); + + testWidgets('push shell route in another shell route', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/120791. + final UniqueKey b = UniqueKey(); + final UniqueKey a = UniqueKey(); + final List routes = [ + ShellRoute( + builder: (_, __, Widget child) { + return Scaffold( + appBar: AppBar(title: const Text('shell1')), + body: child, + ); + }, + routes: [ + GoRoute( + path: '/a', + builder: (_, __) => DummyScreen(key: a), + ), + ], + ), + ShellRoute( + builder: (_, __, Widget child) { + return Scaffold( + appBar: AppBar(title: const Text('shell2')), + body: child, + ); + }, + routes: [ + GoRoute( + path: '/b', + builder: (_, __) => DummyScreen(key: b), + ), + ], + ), + ]; + final GoRouter router = + await createRouter(routes, tester, initialLocation: '/a'); + + expect(find.text('shell1'), findsOneWidget); + expect(find.byKey(a), findsOneWidget); + + router.push('/b'); + await tester.pumpAndSettle(); + expect(find.text('shell1'), findsNothing); + expect(find.byKey(a), findsNothing); + expect(find.text('shell2'), findsOneWidget); + expect(find.byKey(b), findsOneWidget); + }); + + testWidgets('push inside or outside shell route', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/120665. + final UniqueKey inside = UniqueKey(); + final UniqueKey outside = UniqueKey(); + final List routes = [ + ShellRoute( + builder: (_, __, Widget child) { + return Scaffold( + appBar: AppBar(title: const Text('shell')), + body: child, + ); + }, + routes: [ + GoRoute( + path: '/in', + builder: (_, __) => DummyScreen(key: inside), + ), + ], + ), + GoRoute( + path: '/out', + builder: (_, __) => DummyScreen(key: outside), + ), + ]; + final GoRouter router = + await createRouter(routes, tester, initialLocation: '/out'); + + expect(find.text('shell'), findsNothing); + expect(find.byKey(outside), findsOneWidget); + + router.push('/in'); + await tester.pumpAndSettle(); + expect(find.text('shell'), findsOneWidget); + expect(find.byKey(outside), findsNothing); + expect(find.byKey(inside), findsOneWidget); + + router.push('/out'); + await tester.pumpAndSettle(); + expect(find.text('shell'), findsNothing); + expect(find.byKey(outside), findsOneWidget); + expect(find.byKey(inside), findsNothing); + }); + + testWidgets('complex case 1', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/113001. + final UniqueKey a = UniqueKey(); + final UniqueKey b = UniqueKey(); + final UniqueKey c = UniqueKey(); + final UniqueKey d = UniqueKey(); + final UniqueKey e = UniqueKey(); + final List routes = [ + ShellRoute( + builder: (_, __, Widget child) { + return Scaffold( + appBar: AppBar(title: const Text('shell')), + body: child, + ); + }, + routes: [ + GoRoute( + path: '/a', + builder: (_, __) => DummyScreen(key: a), + ), + GoRoute( + path: '/c', + builder: (_, __) => DummyScreen(key: c), + ), + ], + ), + GoRoute( + path: '/d', + builder: (_, __) => DummyScreen(key: d), + routes: [ + GoRoute( + path: 'e', + builder: (_, __) => DummyScreen(key: e), + ), + ], + ), + GoRoute( + path: '/b', + builder: (_, __) => DummyScreen(key: b), + ), + ]; + final GoRouter router = + await createRouter(routes, tester, initialLocation: '/a'); + + expect(find.text('shell'), findsOneWidget); + expect(find.byKey(a), findsOneWidget); + + router.push('/b'); + await tester.pumpAndSettle(); + expect(find.text('shell'), findsNothing); + expect(find.byKey(a), findsNothing); + expect(find.byKey(b), findsOneWidget); + + router.pop(); + await tester.pumpAndSettle(); + expect(find.text('shell'), findsOneWidget); + expect(find.byKey(a), findsOneWidget); + + router.go('/c'); + await tester.pumpAndSettle(); + expect(find.text('shell'), findsOneWidget); + expect(find.byKey(c), findsOneWidget); + + router.push('/d'); + await tester.pumpAndSettle(); + expect(find.text('shell'), findsNothing); + expect(find.byKey(d), findsOneWidget); + + router.push('/d/e'); + await tester.pumpAndSettle(); + expect(find.text('shell'), findsNothing); + expect(find.byKey(e), findsOneWidget); + }); +} diff --git a/packages/go_router/test/inherited_test.dart b/packages/go_router/test/inherited_test.dart index 673462f09bf9..6c6d965ceb34 100644 --- a/packages/go_router/test/inherited_test.dart +++ b/packages/go_router/test/inherited_test.dart @@ -81,6 +81,33 @@ void main() { await tester.tap(find.text('My Page')); expect(router.latestPushedName, 'my_page'); }); + + testWidgets('builder can access GoRouter', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/110512. + late final GoRouter buildContextRouter; + final GoRouter router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (BuildContext context, __) { + buildContextRouter = GoRouter.of(context); + return const DummyScreen(); + }, + ) + ], + ); + + await tester.pumpWidget( + MaterialApp.router( + routeInformationProvider: router.routeInformationProvider, + routeInformationParser: router.routeInformationParser, + routerDelegate: router.routerDelegate), + ); + + expect(buildContextRouter, isNotNull); + expect(buildContextRouter, equals(router)); + }); } bool setupInheritedGoRouterChange({ diff --git a/packages/go_router/test/match_test.dart b/packages/go_router/test/match_test.dart index ea6d72db0d5c..57951b2ae236 100644 --- a/packages/go_router/test/match_test.dart +++ b/packages/go_router/test/match_test.dart @@ -16,44 +16,20 @@ void main() { builder: _builder, ); final Map pathParameters = {}; - final RouteMatch? match = RouteMatch.match( + final List matches = RouteMatchBase.match( route: route, - remainingLocation: '/users/123', - matchedLocation: '', - matchedPath: '', pathParameters: pathParameters, + uri: Uri.parse('/users/123'), + rootNavigatorKey: GlobalKey(), ); - if (match == null) { - fail('Null match'); - } + expect(matches.length, 1); + final RouteMatchBase match = matches.first; expect(match.route, route); expect(match.matchedLocation, '/users/123'); expect(pathParameters['userId'], '123'); expect(match.pageKey, isNotNull); }); - test('matchedLocation', () { - final GoRoute route = GoRoute( - path: 'users/:userId', - builder: _builder, - ); - final Map pathParameters = {}; - final RouteMatch? match = RouteMatch.match( - route: route, - remainingLocation: 'users/123', - matchedLocation: '/home', - matchedPath: '/home', - pathParameters: pathParameters, - ); - if (match == null) { - fail('Null match'); - } - expect(match.route, route); - expect(match.matchedLocation, '/home/users/123'); - expect(pathParameters['userId'], '123'); - expect(match.pageKey, isNotNull); - }); - test('ShellRoute has a unique pageKey', () { final ShellRoute route = ShellRoute( builder: _shellBuilder, @@ -65,17 +41,14 @@ void main() { ], ); final Map pathParameters = {}; - final RouteMatch? match = RouteMatch.match( + final List matches = RouteMatchBase.match( route: route, - remainingLocation: 'users/123', - matchedLocation: '/home', - matchedPath: '/home', + uri: Uri.parse('/users/123'), + rootNavigatorKey: GlobalKey(), pathParameters: pathParameters, ); - if (match == null) { - fail('Null match'); - } - expect(match.pageKey, isNotNull); + expect(matches.length, 1); + expect(matches.first.pageKey, isNotNull); }); test('ShellRoute Match has stable unique key', () { @@ -89,51 +62,136 @@ void main() { ], ); final Map pathParameters = {}; - final RouteMatch? match1 = RouteMatch.match( + final List matches1 = RouteMatchBase.match( route: route, - remainingLocation: 'users/123', - matchedLocation: '/home', - matchedPath: '/home', pathParameters: pathParameters, + uri: Uri.parse('/users/123'), + rootNavigatorKey: GlobalKey(), ); - - final RouteMatch? match2 = RouteMatch.match( + final List matches2 = RouteMatchBase.match( route: route, - remainingLocation: 'users/1234', - matchedLocation: '/home', - matchedPath: '/home', pathParameters: pathParameters, + uri: Uri.parse('/users/1234'), + rootNavigatorKey: GlobalKey(), ); - - expect(match1!.pageKey, match2!.pageKey); + expect(matches1.length, 1); + expect(matches2.length, 1); + expect(matches1.first.pageKey, matches2.first.pageKey); }); test('GoRoute Match has stable unique key', () { final GoRoute route = GoRoute( - path: 'users/:userId', + path: '/users/:userId', builder: _builder, ); final Map pathParameters = {}; - final RouteMatch? match1 = RouteMatch.match( + final List matches1 = RouteMatchBase.match( route: route, - remainingLocation: 'users/123', - matchedLocation: '/home', - matchedPath: '/home', + uri: Uri.parse('/users/123'), + rootNavigatorKey: GlobalKey(), pathParameters: pathParameters, ); - final RouteMatch? match2 = RouteMatch.match( + final List matches2 = RouteMatchBase.match( route: route, - remainingLocation: 'users/1234', - matchedLocation: '/home', - matchedPath: '/home', + uri: Uri.parse('/users/1234'), + rootNavigatorKey: GlobalKey(), pathParameters: pathParameters, ); - - expect(match1!.pageKey, match2!.pageKey); + expect(matches1.length, 1); + expect(matches2.length, 1); + expect(matches1.first.pageKey, matches2.first.pageKey); }); }); + test('complex parentNavigatorKey works', () { + final GlobalKey root = GlobalKey(); + final GlobalKey shell1 = GlobalKey(); + final GlobalKey shell2 = GlobalKey(); + final GoRoute route = GoRoute( + path: '/', + builder: _builder, + routes: [ + ShellRoute( + navigatorKey: shell1, + builder: _shellBuilder, + routes: [ + GoRoute( + path: 'a', + builder: _builder, + routes: [ + GoRoute( + parentNavigatorKey: root, + path: 'b', + builder: _builder, + routes: [ + ShellRoute( + navigatorKey: shell2, + builder: _shellBuilder, + routes: [ + GoRoute( + path: 'c', + builder: _builder, + routes: [ + GoRoute( + parentNavigatorKey: root, + path: 'd', + builder: _builder, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ); + final Map pathParameters = {}; + final List matches = RouteMatchBase.match( + route: route, + pathParameters: pathParameters, + uri: Uri.parse('/a/b/c/d'), + rootNavigatorKey: root, + ); + expect(matches.length, 4); + expect( + matches[0].route, + isA().having( + (GoRoute route) => route.path, + 'path', + '/', + ), + ); + expect( + matches[1].route, + isA().having( + (ShellRoute route) => route.navigatorKey, + 'navigator key', + shell1, + ), + ); + expect( + matches[2].route, + isA().having( + (GoRoute route) => route.path, + 'path', + 'b', + ), + ); + expect( + matches[3].route, + isA().having( + (GoRoute route) => route.path, + 'path', + 'd', + ), + ); + }); + group('ImperativeRouteMatch', () { final RouteMatchList matchList1 = RouteMatchList( matches: [ diff --git a/packages/go_router/test/matching_test.dart b/packages/go_router/test/matching_test.dart index c53ca13bf722..89cbccb8a5ee 100644 --- a/packages/go_router/test/matching_test.dart +++ b/packages/go_router/test/matching_test.dart @@ -35,37 +35,35 @@ void main() { const Placeholder(), ); final Map params1 = {}; - final RouteMatch match1 = RouteMatch.match( + final List match1 = RouteMatchBase.match( route: route, - remainingLocation: '/page-0', - matchedLocation: '', - matchedPath: '', + uri: Uri.parse('/page-0'), + rootNavigatorKey: GlobalKey(), pathParameters: params1, - )!; + ); final Map params2 = {}; - final RouteMatch match2 = RouteMatch.match( + final List match2 = RouteMatchBase.match( route: route, - remainingLocation: '/page-0', - matchedLocation: '', - matchedPath: '', + uri: Uri.parse('/page-0'), + rootNavigatorKey: GlobalKey(), pathParameters: params2, - )!; + ); final RouteMatchList matches1 = RouteMatchList( - matches: [match1], + matches: match1, uri: Uri.parse(''), pathParameters: params1, ); final RouteMatchList matches2 = RouteMatchList( - matches: [match2], + matches: match2, uri: Uri.parse(''), pathParameters: params2, ); final RouteMatchList matches3 = RouteMatchList( - matches: [match2], + matches: match2, uri: Uri.parse('/page-0'), pathParameters: params2, ); diff --git a/packages/go_router/test/parser_test.dart b/packages/go_router/test/parser_test.dart index e5b68fa0b43d..ddcacd0a4f43 100644 --- a/packages/go_router/test/parser_test.dart +++ b/packages/go_router/test/parser_test.dart @@ -62,7 +62,7 @@ void main() { RouteMatchList matchesObj = await parser.parseRouteInformationWithDependencies( createRouteInformation('/'), context); - List matches = matchesObj.matches; + List matches = matchesObj.matches; expect(matches.length, 1); expect(matchesObj.uri.toString(), '/'); expect(matchesObj.extra, isNull); @@ -238,7 +238,7 @@ void main() { final RouteMatchList matchesObj = await parser.parseRouteInformationWithDependencies( createRouteInformation('/def'), context); - final List matches = matchesObj.matches; + final List matches = matchesObj.matches; expect(matches.length, 0); expect(matchesObj.uri.toString(), '/def'); expect(matchesObj.extra, isNull); @@ -303,7 +303,7 @@ void main() { final RouteMatchList matchesObj = await parser.parseRouteInformationWithDependencies( createRouteInformation('/123/family/456'), context); - final List matches = matchesObj.matches; + final List matches = matchesObj.matches; expect(matches.length, 2); expect(matchesObj.uri.toString(), '/123/family/456'); @@ -347,7 +347,7 @@ void main() { final RouteMatchList matchesObj = await parser.parseRouteInformationWithDependencies( createRouteInformation('/random/uri'), context); - final List matches = matchesObj.matches; + final List matches = matchesObj.matches; expect(matches.length, 2); expect(matchesObj.uri.toString(), '/123/family/345'); @@ -387,7 +387,7 @@ void main() { final RouteMatchList matchesObj = await parser.parseRouteInformationWithDependencies( createRouteInformation('/redirect'), context); - final List matches = matchesObj.matches; + final List matches = matchesObj.matches; expect(matches.length, 2); expect(matchesObj.uri.toString(), '/123/family/345'); @@ -440,7 +440,7 @@ void main() { final RouteMatchList matchesObj = await parser.parseRouteInformationWithDependencies( createRouteInformation('/abd'), context); - final List matches = matchesObj.matches; + final List matches = matchesObj.matches; expect(matches, hasLength(0)); expect(matchesObj.error, isNotNull); @@ -484,9 +484,11 @@ void main() { final RouteMatchList matchesObj = await parser.parseRouteInformationWithDependencies( createRouteInformation('/a'), context); - final List matches = matchesObj.matches; + final List matches = matchesObj.matches; - expect(matches, hasLength(2)); + expect(matches, hasLength(1)); + final ShellRouteMatch match = matches.first as ShellRouteMatch; + expect(match.matches, hasLength(1)); expect(matchesObj.error, isNull); }); } diff --git a/packages/go_router/test/test_helpers.dart b/packages/go_router/test/test_helpers.dart index 3db0b4579086..112617832001 100644 --- a/packages/go_router/test/test_helpers.dart +++ b/packages/go_router/test/test_helpers.dart @@ -356,14 +356,6 @@ StatefulShellRouteBuilder mockStackedShellBuilder = (BuildContext context, return navigationShell; }; -RouteMatch createRouteMatch(RouteBase route, String location) { - return RouteMatch( - route: route, - matchedLocation: location, - pageKey: ValueKey(location), - ); -} - /// A routing config that is never going to change. class ConstantRoutingConfig extends ValueListenable { const ConstantRoutingConfig(this.value);