From 0f8f43375e4e384506846a880609e00087b38266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Lo=CC=88fstrand?= Date: Tue, 20 Jun 2023 00:04:16 +0200 Subject: [PATCH 1/4] First attempt at re-implementing support for branch preload --- .../others/custom_stateful_shell_route.dart | 122 +++++++++--------- packages/go_router/lib/src/builder.dart | 27 +++- packages/go_router/lib/src/route.dart | 109 +++++++++++++--- packages/go_router/lib/src/typedefs.dart | 5 +- 4 files changed, 181 insertions(+), 82 deletions(-) diff --git a/packages/go_router/example/lib/others/custom_stateful_shell_route.dart b/packages/go_router/example/lib/others/custom_stateful_shell_route.dart index 5fbe2b6d2868..a43e98b1de5b 100644 --- a/packages/go_router/example/lib/others/custom_stateful_shell_route.dart +++ b/packages/go_router/example/lib/others/custom_stateful_shell_route.dart @@ -10,6 +10,10 @@ final GlobalKey _rootNavigatorKey = GlobalKey(debugLabel: 'root'); final GlobalKey _tabANavigatorKey = GlobalKey(debugLabel: 'tabANav'); +final GlobalKey _tabB1NavigatorKey = + GlobalKey(debugLabel: 'tabB1Nav'); +final GlobalKey _tabB2NavigatorKey = + GlobalKey(debugLabel: 'tabB2Nav'); // This example demonstrates how to setup nested navigation using a // BottomNavigationBar, where each bar item uses its own persistent navigator, @@ -80,71 +84,68 @@ class NestedTabNavigationExampleApp extends StatelessWidget { // The route branch for the third tab of the bottom navigation bar. StatefulShellBranch( + // To enable preloading of the initial locations of branches, pass + // 'false' for the parameter lazy. + lazy: false, // StatefulShellBranch will automatically use the first descendant // GoRoute as the initial location of the branch. If another route // is desired, specify the location of it using the defaultLocation // parameter. - // defaultLocation: '/c2', + // defaultLocation: '/b1', routes: [ - StatefulShellRoute( + StatefulShellRoute.indexedStack( builder: (BuildContext context, GoRouterState state, StatefulNavigationShell navigationShell) { - // Just like with the top level StatefulShellRoute, no - // customization is done in the builder function. - return navigationShell; + return TabbedRootScreen(navigationShell: navigationShell); }, - navigatorContainerBuilder: (BuildContext context, - StatefulNavigationShell navigationShell, - List children) { - // Returning a customized container for the branch - // Navigators (i.e. the `List children` argument). - // - // See TabbedRootScreen for more details on how the children - // are managed (in a TabBarView). - return TabbedRootScreen( - navigationShell: navigationShell, children: children); - }, - // This bottom tab uses a nested shell, wrapping sub routes in a - // top TabBar. branches: [ - StatefulShellBranch(routes: [ - GoRoute( - path: '/b1', - builder: (BuildContext context, GoRouterState state) => - const TabScreen( - label: 'B1', detailsPath: '/b1/details'), - routes: [ + StatefulShellBranch( + navigatorKey: _tabB1NavigatorKey, + routes: [ GoRoute( - path: 'details', + path: '/b1', builder: (BuildContext context, GoRouterState state) => - const DetailsScreen( - label: 'B1', - withScaffold: false, - ), + const TabScreen( + label: 'B1', detailsPath: '/b1/details'), + routes: [ + GoRoute( + path: 'details', + builder: + (BuildContext context, GoRouterState state) => + const DetailsScreen( + label: 'B1', + withScaffold: false, + ), + ), + ], ), - ], - ), - ]), - StatefulShellBranch(routes: [ - GoRoute( - path: '/b2', - builder: (BuildContext context, GoRouterState state) => - const TabScreen( - label: 'B2', detailsPath: '/b2/details'), - routes: [ + ]), + StatefulShellBranch( + navigatorKey: _tabB2NavigatorKey, + // To enable preloading for all nested branches, set lazy to + // 'false'. + lazy: false, + routes: [ GoRoute( - path: 'details', + path: '/b2', builder: (BuildContext context, GoRouterState state) => - const DetailsScreen( - label: 'B2', - withScaffold: false, - ), + const TabScreen( + label: 'B2', detailsPath: '/b2/details'), + routes: [ + GoRoute( + path: 'details', + builder: + (BuildContext context, GoRouterState state) => + const DetailsScreen( + label: 'B2', + withScaffold: false, + ), + ), + ], ), - ], - ), - ]), + ]), ], ), ], @@ -376,23 +377,20 @@ class DetailsScreenState extends State { /// Builds a nested shell using a [TabBar] and [TabBarView]. class TabbedRootScreen extends StatefulWidget { /// Constructs a TabbedRootScreen - const TabbedRootScreen( - {required this.navigationShell, required this.children, super.key}); + const TabbedRootScreen({required this.navigationShell, super.key}); /// The current state of the parent StatefulShellRoute. final StatefulNavigationShell navigationShell; - /// The children (branch Navigators) to display in the [TabBarView]. - final List children; - @override State createState() => _TabbedRootScreenState(); } class _TabbedRootScreenState extends State with SingleTickerProviderStateMixin { + late final int branchCount = widget.navigationShell.route.branches.length; late final TabController _tabController = TabController( - length: widget.children.length, + length: branchCount, vsync: this, initialIndex: widget.navigationShell.currentIndex); @@ -404,9 +402,9 @@ class _TabbedRootScreenState extends State @override Widget build(BuildContext context) { - final List tabs = widget.children - .mapIndexed((int i, _) => Tab(text: 'Tab ${i + 1}')) - .toList(); + final List tabs = + List.generate(branchCount, (int i) => Tab(text: 'Tab ${i + 1}')) + .toList(); return Scaffold( appBar: AppBar( @@ -416,10 +414,7 @@ class _TabbedRootScreenState extends State tabs: tabs, onTap: (int tappedIndex) => _onTabTap(context, tappedIndex), )), - body: TabBarView( - controller: _tabController, - children: widget.children, - ), + body: widget.navigationShell, ); } @@ -441,6 +436,11 @@ class TabScreen extends StatelessWidget { @override Widget build(BuildContext context) { + /// If preloading is enabled on the top StatefulShellRoute, this will be + /// printed directly after the app has been started, but only for the route + /// that is the initial location ('/b1') + debugPrint('Building TabScreen - $label'); + return Center( child: Column( mainAxisSize: MainAxisSize.min, diff --git a/packages/go_router/lib/src/builder.dart b/packages/go_router/lib/src/builder.dart index c98f2f8bd123..ade3fd581cbd 100644 --- a/packages/go_router/lib/src/builder.dart +++ b/packages/go_router/lib/src/builder.dart @@ -243,19 +243,35 @@ class RouteBuilder { _buildRecursive(context, matchList, startIndex + 1, pagePopContext, routerNeglect, keyToPages, shellNavigatorKey, registry); - final HeroController heroController = _goHeroCache.putIfAbsent( + _goHeroCache.putIfAbsent( shellNavigatorKey, () => _getHeroController(context)); + // Build pages for preloadable navigators + final Map preloadableNavigators = + route.preloadableNavigators(configuration); + for (final MapEntry entry + in preloadableNavigators.entries) { + if (entry.key == shellNavigatorKey) { + // Skip the current navigator + continue; + } + // TODO: More assertion? + assert(entry.value.matches[startIndex].route == route); + _buildRecursive(context, entry.value, startIndex + 1, pagePopContext, + routerNeglect, keyToPages, entry.key, registry); + _goHeroCache.putIfAbsent(entry.key, () => _getHeroController(context)); + } + // Build the Navigator for this shell route - Widget buildShellNavigator( + Widget buildShellNavigator(NavigatorKey navigatorKey, List? observers, String? restorationScopeId) { return _buildNavigator( pagePopContext.onPopPage, - keyToPages[shellNavigatorKey]!, - shellNavigatorKey, + keyToPages[navigatorKey]!, + navigatorKey, observers: observers ?? const [], restorationScopeId: restorationScopeId, - heroController: heroController, + heroController: _goHeroCache[navigatorKey], ); } @@ -265,6 +281,7 @@ class RouteBuilder { routerState: state, navigatorKey: shellNavigatorKey, routeMatchList: matchList, + preloadedMatchLists: preloadableNavigators, navigatorBuilder: buildShellNavigator, ); diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index ba656326e328..1a880047cf08 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -353,6 +353,10 @@ abstract class ShellRouteBase extends RouteBase { /// immediate sub-route of this shell route. GlobalKey navigatorKeyForSubRoute(RouteBase subRoute); + Map, RouteMatchList> preloadableNavigators( + RouteConfiguration routeConfiguration) => + , RouteMatchList>{}; + /// Returns the keys for the [Navigator]s associated with this shell route. Iterable> get _navigatorKeys => >[]; @@ -374,6 +378,7 @@ class ShellRouteContext { required this.routerState, required this.navigatorKey, required this.routeMatchList, + required this.preloadedMatchLists, required this.navigatorBuilder, }); @@ -391,6 +396,8 @@ class ShellRouteContext { /// associated shell route. final RouteMatchList routeMatchList; + final Map preloadedMatchLists; + /// Function used to build the [Navigator] for the current route. final NavigatorBuilder navigatorBuilder; } @@ -530,8 +537,8 @@ class ShellRoute extends ShellRouteBase { Widget? buildWidget(BuildContext context, GoRouterState state, ShellRouteContext shellRouteContext) { if (builder != null) { - final Widget navigator = - shellRouteContext.navigatorBuilder(observers, restorationScopeId); + final Widget navigator = shellRouteContext.navigatorBuilder( + shellRouteContext.navigatorKey, observers, restorationScopeId); return builder!(context, state, navigator); } return null; @@ -541,8 +548,8 @@ class ShellRoute extends ShellRouteBase { Page? buildPage(BuildContext context, GoRouterState state, ShellRouteContext shellRouteContext) { if (pageBuilder != null) { - final Widget navigator = - shellRouteContext.navigatorBuilder(observers, restorationScopeId); + final Widget navigator = shellRouteContext.navigatorBuilder( + shellRouteContext.navigatorKey, observers, restorationScopeId); return pageBuilder!(context, state, navigator); } return null; @@ -661,6 +668,9 @@ class StatefulShellRoute extends ShellRouteBase { 'Navigator keys must be unique'), assert(_debugValidateParentNavigatorKeys(branches)), assert(_debugValidateRestorationScopeIds(restorationScopeId, branches)), + _preloadableBranches = branches + .where((StatefulShellBranch b) => b._shouldPreload) + .toList(growable: false), super._(routes: _routes(branches)); /// Constructs a StatefulShellRoute that uses an [IndexedStack] for its @@ -739,6 +749,9 @@ class StatefulShellRoute extends ShellRouteBase { /// [StatefulShellBranch.navigatorKey]. final List branches; + /// Cached list of branches that should be preloaded. + final List _preloadableBranches; + final GlobalKey _shellStateKey = GlobalKey(); @@ -769,6 +782,29 @@ class StatefulShellRoute extends ShellRouteBase { return branch!.navigatorKey; } + @override + Map, RouteMatchList> preloadableNavigators( + RouteConfiguration routeConfiguration) { + // Return quickly if there are no preloadable branches. + if (_preloadableBranches.isEmpty) { + return , RouteMatchList>{}; + } + + final Map, RouteMatchList> preloadable = + , RouteMatchList>{}; + final StatefulNavigationShellState? shellState = + _shellStateKey.currentState; + + for (final StatefulShellBranch branch in _preloadableBranches) { + final bool loaded = shellState?._isBranchLoaded(branch) ?? false; + if (!loaded) { + preloadable[branch.navigatorKey] = routeConfiguration.findMatch( + branch._effectiveInitialBranchLocation(routeConfiguration)); + } + } + return preloadable; + } + @override Iterable> get _navigatorKeys => branches.map((StatefulShellBranch b) => b.navigatorKey); @@ -847,6 +883,7 @@ class StatefulShellBranch { this.initialLocation, this.restorationScopeId, this.observers, + this.lazy = true, }) : navigatorKey = navigatorKey ?? GlobalKey(); /// The [GlobalKey] to be used by the [Navigator] built for this branch. @@ -877,12 +914,41 @@ class StatefulShellBranch { /// The observers parameter is used by the [Navigator] built for this branch. final List? observers; + /// Whether this route branch should be loaded only when navigating to it for + /// the first time (the default behavior, i.e. 'true'), or if it should be + /// eagerly loaded (preloaded). + /// + /// If this property is false, the branch will be loaded immediately when the + /// associated [StatefulShellRoute] is visited for the first time. In that + /// case, the branch will be preloaded by navigating to the initial location + /// (see [initialLocation]). + /// + /// *Note:* The primary purpose of branch preloading is to enhance the user + /// experience when switching branches, which might for instance involve + /// preparing the UI for animated transitions etc. Care must be taken to + /// **keep the preloading to an absolute minimum** to avoid any unnecessary + /// resource use. + final bool lazy; + /// The default route of this branch, i.e. the first descendant [GoRoute]. /// /// This route will be used when loading the branch for the first time, if /// an [initialLocation] has not been provided. GoRoute? get defaultRoute => RouteBase.routesRecursively(routes).whereType().firstOrNull; + + bool get _shouldPreload => !lazy; + + String _effectiveInitialBranchLocation( + RouteConfiguration routeConfiguration) { + if (initialLocation != null) { + return initialLocation!; + } else { + /// Recursively traverses the routes of the provided StackedShellBranch to + /// find the first GoRoute, from which a full path will be derived. + return routeConfiguration.locationForRoute(defaultRoute!)!; + } + } } /// Builder for a custom container for the branch Navigators of a @@ -972,15 +1038,7 @@ class StatefulNavigationShell extends StatefulWidget { final StatefulShellRoute route = shellRouteContext.route as StatefulShellRoute; final StatefulShellBranch branch = route.branches[index]; - final String? initialLocation = branch.initialLocation; - if (initialLocation != null) { - return initialLocation; - } else { - /// Recursively traverses the routes of the provided StackedShellBranch to - /// find the first GoRoute, from which a full path will be derived. - final GoRoute route = branch.defaultRoute!; - return _router.configuration.locationForRoute(route)!; - } + return branch._effectiveInitialBranchLocation(_router.configuration); } @override @@ -1053,6 +1111,9 @@ class StatefulNavigationShellState extends State RouteMatchList? _matchListForBranch(int index) => _branchLocations[route.branches[index]]?.value; + bool _isBranchLoaded(StatefulShellBranch branch) => + _branchNavigators[branch.navigatorKey] != null; + /// Creates a new RouteMatchList that is scoped to the Navigators of the /// current shell route or it's descendants. This involves removing all the /// trailing imperative matches from the RouterMatchList that are targeted at @@ -1097,8 +1158,26 @@ class StatefulNavigationShellState extends State final bool locationChanged = previousBranchLocation != currentBranchLocation; if (locationChanged || !hasExistingNavigator) { - _branchNavigators[branch.navigatorKey] = shellRouteContext - .navigatorBuilder(branch.observers, branch.restorationScopeId); + _branchNavigators[branch.navigatorKey] = + shellRouteContext.navigatorBuilder( + branch.navigatorKey, branch.observers, branch.restorationScopeId); + } + + _preloadBranches(); + } + + void _preloadBranches() { + final ShellRouteContext shellRouteContext = widget.shellRouteContext; + final Map preloadedMatchLists = + shellRouteContext.preloadedMatchLists; + for (final StatefulShellBranch branch in route.branches) { + final RouteMatchList? matches = preloadedMatchLists[branch.navigatorKey]; + if (matches != null && !_isBranchLoaded(branch)) { + _branchLocation(branch, false).value = matches; + _branchNavigators[branch.navigatorKey] = + shellRouteContext.navigatorBuilder(branch.navigatorKey, + branch.observers, branch.restorationScopeId); + } } } diff --git a/packages/go_router/lib/src/typedefs.dart b/packages/go_router/lib/src/typedefs.dart index 7a4071529f03..22808f586749 100644 --- a/packages/go_router/lib/src/typedefs.dart +++ b/packages/go_router/lib/src/typedefs.dart @@ -59,5 +59,8 @@ typedef GoRouterRedirect = FutureOr Function( BuildContext context, GoRouterState state); /// Signature for functions used to build Navigators -typedef NavigatorBuilder = Widget Function( +typedef NavigatorBuilder = Widget Function(NavigatorKey navigatorKey, List? observers, String? restorationScopeId); + +/// Alias for navigator keys ([GlobalKey]). +typedef NavigatorKey = GlobalKey; From 6b441e9b495e086b529013c135b49ad565c42b5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Lo=CC=88fstrand?= Date: Sun, 25 Jun 2023 09:19:54 +0200 Subject: [PATCH 2/4] Added tests and docs. --- packages/go_router/lib/src/builder.dart | 15 +- packages/go_router/lib/src/route.dart | 18 +- packages/go_router/lib/src/typedefs.dart | 9 +- packages/go_router/test/go_router_test.dart | 173 ++++++++++++++++++++ 4 files changed, 198 insertions(+), 17 deletions(-) diff --git a/packages/go_router/lib/src/builder.dart b/packages/go_router/lib/src/builder.dart index ade3fd581cbd..ccd5403b8add 100644 --- a/packages/go_router/lib/src/builder.dart +++ b/packages/go_router/lib/src/builder.dart @@ -247,23 +247,28 @@ class RouteBuilder { shellNavigatorKey, () => _getHeroController(context)); // Build pages for preloadable navigators - final Map preloadableNavigators = - route.preloadableNavigators(configuration); - for (final MapEntry entry + final Map, RouteMatchList> + preloadableNavigators = + route.preloadableNavigatorLocations(configuration); + for (final MapEntry, RouteMatchList> entry in preloadableNavigators.entries) { if (entry.key == shellNavigatorKey) { // Skip the current navigator continue; } - // TODO: More assertion? + // Preloaded RouteMatchList must point to a route that is a descendant + // of the current shell route (initial/default location of a branch is + // fully validated in RouteConfiguration). assert(entry.value.matches[startIndex].route == route); + + // Build the pages for this preloadable navigator _buildRecursive(context, entry.value, startIndex + 1, pagePopContext, routerNeglect, keyToPages, entry.key, registry); _goHeroCache.putIfAbsent(entry.key, () => _getHeroController(context)); } // Build the Navigator for this shell route - Widget buildShellNavigator(NavigatorKey navigatorKey, + Widget buildShellNavigator(GlobalKey navigatorKey, List? observers, String? restorationScopeId) { return _buildNavigator( pagePopContext.onPopPage, diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index 1a880047cf08..67eefbd1d712 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -353,7 +353,9 @@ abstract class ShellRouteBase extends RouteBase { /// immediate sub-route of this shell route. GlobalKey navigatorKeyForSubRoute(RouteBase subRoute); - Map, RouteMatchList> preloadableNavigators( + /// Returns the locations for any [Navigator]s of this shell route that are + /// eligible for preloading. + Map, RouteMatchList> preloadableNavigatorLocations( RouteConfiguration routeConfiguration) => , RouteMatchList>{}; @@ -388,15 +390,17 @@ class ShellRouteContext { /// The current route state associated with [route]. final GoRouterState routerState; - /// The [Navigator] key to be used for the nested navigation associated with + /// The [Navigator] key of the current Navigator within the associated /// [route]. final GlobalKey navigatorKey; - /// The route match list representing the current location within the - /// associated shell route. + /// The route match list representing the location of the current [Navigator] + /// within the associated [route]. final RouteMatchList routeMatchList; - final Map preloadedMatchLists; + /// The route match lists representing the locations of any additional + /// preloaded [Navigator]s within the associated [route]. + final Map, RouteMatchList> preloadedMatchLists; /// Function used to build the [Navigator] for the current route. final NavigatorBuilder navigatorBuilder; @@ -783,7 +787,7 @@ class StatefulShellRoute extends ShellRouteBase { } @override - Map, RouteMatchList> preloadableNavigators( + Map, RouteMatchList> preloadableNavigatorLocations( RouteConfiguration routeConfiguration) { // Return quickly if there are no preloadable branches. if (_preloadableBranches.isEmpty) { @@ -1168,7 +1172,7 @@ class StatefulNavigationShellState extends State void _preloadBranches() { final ShellRouteContext shellRouteContext = widget.shellRouteContext; - final Map preloadedMatchLists = + final Map, RouteMatchList> preloadedMatchLists = shellRouteContext.preloadedMatchLists; for (final StatefulShellBranch branch in route.branches) { final RouteMatchList? matches = preloadedMatchLists[branch.navigatorKey]; diff --git a/packages/go_router/lib/src/typedefs.dart b/packages/go_router/lib/src/typedefs.dart index 22808f586749..40a50f8bfdbd 100644 --- a/packages/go_router/lib/src/typedefs.dart +++ b/packages/go_router/lib/src/typedefs.dart @@ -59,8 +59,7 @@ typedef GoRouterRedirect = FutureOr Function( BuildContext context, GoRouterState state); /// Signature for functions used to build Navigators -typedef NavigatorBuilder = Widget Function(NavigatorKey navigatorKey, - List? observers, String? restorationScopeId); - -/// Alias for navigator keys ([GlobalKey]). -typedef NavigatorKey = GlobalKey; +typedef NavigatorBuilder = Widget Function( + GlobalKey navigatorKey, + List? observers, + String? restorationScopeId); diff --git a/packages/go_router/test/go_router_test.dart b/packages/go_router/test/go_router_test.dart index 2ebb4afca774..da51b6181434 100644 --- a/packages/go_router/test/go_router_test.dart +++ b/packages/go_router/test/go_router_test.dart @@ -3847,6 +3847,179 @@ void main() { expect(find.text('Common - X'), findsOneWidget); }); + testWidgets('Preloads routes correctly in a StatefulShellRoute', + (WidgetTester tester) async { + final GlobalKey rootNavigatorKey = + GlobalKey(); + final GlobalKey statefulWidgetKeyA = + GlobalKey(debugLabel: 'A'); + final GlobalKey statefulWidgetKeyB = + GlobalKey(debugLabel: 'B'); + final GlobalKey statefulWidgetKeyC = + GlobalKey(debugLabel: 'C'); + final GlobalKey statefulWidgetKeyD = + GlobalKey(debugLabel: 'D'); + final GlobalKey statefulWidgetKeyE = + GlobalKey(debugLabel: 'E'); + + final List routes = [ + StatefulShellRoute.indexedStack( + builder: mockStackedShellBuilder, + branches: [ + StatefulShellBranch(routes: [ + GoRoute( + path: '/a', + builder: (BuildContext context, GoRouterState state) => + DummyStatefulWidget(key: statefulWidgetKeyA), + ), + ]), + StatefulShellBranch(routes: [ + GoRoute( + path: '/b', + builder: (BuildContext context, GoRouterState state) => + DummyStatefulWidget(key: statefulWidgetKeyB), + ), + ]), + ], + ), + StatefulShellRoute.indexedStack( + builder: mockStackedShellBuilder, + branches: [ + StatefulShellBranch( + lazy: false, + routes: [ + GoRoute( + path: '/c', + builder: (BuildContext context, GoRouterState state) => + DummyStatefulWidget(key: statefulWidgetKeyC), + ), + ], + ), + StatefulShellBranch( + lazy: false, + routes: [ + GoRoute( + path: '/d', + builder: (BuildContext context, GoRouterState state) => + DummyStatefulWidget(key: statefulWidgetKeyD), + ), + ], + ), + StatefulShellBranch( + lazy: false, + initialLocation: '/e/details', + routes: [ + GoRoute( + path: '/e', + builder: (BuildContext context, GoRouterState state) => + const Text('E'), + routes: [ + GoRoute( + path: 'details', + builder: (BuildContext context, GoRouterState state) => + DummyStatefulWidget(key: statefulWidgetKeyE), + ), + ]), + ], + ), + ], + ), + ]; + + final GoRouter router = await createRouter( + routes, + tester, + initialLocation: '/a', + navigatorKey: rootNavigatorKey, + ); + expect(statefulWidgetKeyA.currentState?.counter, equals(0)); + expect(statefulWidgetKeyB.currentState?.counter, null); + expect(statefulWidgetKeyC.currentState?.counter, null); + expect(statefulWidgetKeyD.currentState?.counter, null); + + router.go('/c'); + await tester.pumpAndSettle(); + expect(statefulWidgetKeyC.currentState?.counter, equals(0)); + expect(statefulWidgetKeyD.currentState?.counter, equals(0)); + expect(statefulWidgetKeyE.currentState?.counter, equals(0)); + }); + + testWidgets('Preloads nested routes correctly in a StatefulShellRoute', + (WidgetTester tester) async { + final GlobalKey rootNavigatorKey = + GlobalKey(); + final GlobalKey statefulWidgetKeyA = + GlobalKey(debugLabel: 'A'); + final GlobalKey statefulWidgetKeyB = + GlobalKey(debugLabel: 'B'); + final GlobalKey statefulWidgetKeyC = + GlobalKey(debugLabel: 'C'); + final GlobalKey statefulWidgetKeyD = + GlobalKey(debugLabel: 'D'); + + final List routes = [ + StatefulShellRoute.indexedStack( + builder: mockStackedShellBuilder, + branches: [ + StatefulShellBranch( + lazy: false, + routes: [ + StatefulShellRoute.indexedStack( + builder: mockStackedShellBuilder, + branches: [ + StatefulShellBranch(lazy: false, routes: [ + GoRoute( + path: '/a', + builder: (BuildContext context, GoRouterState state) => + DummyStatefulWidget(key: statefulWidgetKeyA), + ), + ]), + StatefulShellBranch(lazy: false, routes: [ + GoRoute( + path: '/b', + builder: (BuildContext context, GoRouterState state) => + DummyStatefulWidget(key: statefulWidgetKeyB), + ), + ]), + ], + ), + ], + ), + StatefulShellBranch( + lazy: false, + routes: [ + GoRoute( + path: '/c', + builder: (BuildContext context, GoRouterState state) => + DummyStatefulWidget(key: statefulWidgetKeyC), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/d', + builder: (BuildContext context, GoRouterState state) => + DummyStatefulWidget(key: statefulWidgetKeyD), + ), + ], + ), + ], + ), + ]; + + await createRouter( + routes, + tester, + initialLocation: '/c', + navigatorKey: rootNavigatorKey, + ); + expect(statefulWidgetKeyA.currentState?.counter, equals(0)); + expect(statefulWidgetKeyB.currentState?.counter, equals(0)); + expect(statefulWidgetKeyC.currentState?.counter, equals(0)); + expect(statefulWidgetKeyD.currentState?.counter, null); + }); + testWidgets( 'Redirects are correctly handled when switching branch in a ' 'StatefulShellRoute', (WidgetTester tester) async { From ca6184516b117d7331324a3af06e87f4e4395b2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Lo=CC=88fstrand?= Date: Sun, 25 Jun 2023 23:26:40 +0200 Subject: [PATCH 3/4] Bumped version and updated changelog. --- packages/go_router/CHANGELOG.md | 4 ++++ packages/go_router/pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index c41a7d2fb3bb..7eaea942c3b2 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,7 @@ +## 9.1.0 + +- Adds preload support to StatefulShellRoute, configurable via `lazy` parameter on StatefulShellBranch. + ## 9.0.0 - **BREAKING CHANGE**: diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index f977eb998475..24bb28db2b5b 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: 9.0.0 +version: 9.1.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 From d55ab8311e69811b124ce46239a4d360247cb4ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Lo=CC=88fstrand?= Date: Wed, 6 Sep 2023 21:48:14 +0200 Subject: [PATCH 4/4] Refactoring --- .../others/custom_stateful_shell_route.dart | 10 +- packages/go_router/lib/src/builder.dart | 55 ++++---- packages/go_router/lib/src/route.dart | 126 ++++++++++-------- packages/go_router/test/go_router_test.dart | 14 +- 4 files changed, 105 insertions(+), 100 deletions(-) diff --git a/packages/go_router/example/lib/others/custom_stateful_shell_route.dart b/packages/go_router/example/lib/others/custom_stateful_shell_route.dart index a43e98b1de5b..8bbd859ed7ba 100644 --- a/packages/go_router/example/lib/others/custom_stateful_shell_route.dart +++ b/packages/go_router/example/lib/others/custom_stateful_shell_route.dart @@ -85,8 +85,8 @@ class NestedTabNavigationExampleApp extends StatelessWidget { // The route branch for the third tab of the bottom navigation bar. StatefulShellBranch( // To enable preloading of the initial locations of branches, pass - // 'false' for the parameter lazy. - lazy: false, + // 'true' for the parameter preload. + preload: true, // StatefulShellBranch will automatically use the first descendant // GoRoute as the initial location of the branch. If another route // is desired, specify the location of it using the defaultLocation @@ -123,9 +123,9 @@ class NestedTabNavigationExampleApp extends StatelessWidget { ]), StatefulShellBranch( navigatorKey: _tabB2NavigatorKey, - // To enable preloading for all nested branches, set lazy to - // 'false'. - lazy: false, + // To enable preloading for all nested branches, set + // preload to 'true'. + preload: true, routes: [ GoRoute( path: '/b2', diff --git a/packages/go_router/lib/src/builder.dart b/packages/go_router/lib/src/builder.dart index 1b65a6d2d175..836a0cfeb951 100644 --- a/packages/go_router/lib/src/builder.dart +++ b/packages/go_router/lib/src/builder.dart @@ -216,11 +216,15 @@ class RouteBuilder { final RouteBase route = match.route; final GoRouterState state = buildState(matchList, match); Page? page; + + void buildNext(RouteMatchList matches, GlobalKey key) => + _buildRecursive(context, matches, startIndex + 1, pagePopContext, + routerNeglect, keyToPages, key, registry); + if (state.error != null) { page = _buildErrorPage(context, state); keyToPages.putIfAbsent(navigatorKey, () => >[]).add(page); - _buildRecursive(context, matchList, startIndex + 1, pagePopContext, - routerNeglect, keyToPages, navigatorKey, registry); + buildNext(matchList, navigatorKey); } else { // If this RouteBase is for a different Navigator, add it to the // list of out of scope pages @@ -236,8 +240,7 @@ class RouteBuilder { .add(page); } - _buildRecursive(context, matchList, startIndex + 1, pagePopContext, - routerNeglect, keyToPages, navigatorKey, registry); + buildNext(matchList, navigatorKey); } else if (route is ShellRouteBase) { assert(startIndex + 1 < matchList.matches.length, 'Shell routes must always have child routes'); @@ -258,43 +261,33 @@ class RouteBuilder { keyToPages.putIfAbsent(shellNavigatorKey, () => >[]); // Build the remaining pages - _buildRecursive(context, matchList, startIndex + 1, pagePopContext, - routerNeglect, keyToPages, shellNavigatorKey, registry); + buildNext(matchList, shellNavigatorKey); - // final HeroController heroController = _goHeroCache.putIfAbsent( - // shellNavigatorKey, () => _getHeroController(context)); _goHeroCache.putIfAbsent( shellNavigatorKey, () => _getHeroController(context)); - // Build pages for preloadable navigators - final Map, RouteMatchList> - preloadableNavigators = - route.preloadableNavigatorLocations(configuration); - for (final MapEntry, RouteMatchList> entry - in preloadableNavigators.entries) { - if (entry.key == shellNavigatorKey) { - // Skip the current navigator - continue; - } - // Preloaded RouteMatchList must point to a route that is a descendant - // of the current shell route (initial/default location of a branch is - // fully validated in RouteConfiguration). - assert(entry.value.matches[startIndex].route == route); - - // Build the pages for this preloadable navigator - _buildRecursive(context, entry.value, startIndex + 1, pagePopContext, - routerNeglect, keyToPages, entry.key, registry); - _goHeroCache.putIfAbsent( - entry.key, () => _getHeroController(context)); - } - // Build the Navigator for this shell route Widget buildShellNavigator( GlobalKey navigatorKey, List? observers, String? restorationScopeId, { bool requestFocus = true, + RouteMatchList? preloadedNavigatorMatches, }) { + if (preloadedNavigatorMatches != null && + keyToPages[navigatorKey] == null) { + // Preloaded RouteMatchList must point to a route that is a + // descendant of the current shell route. + assert( + preloadedNavigatorMatches.matches[startIndex].route == route); + + // Build the pages for this preloadable navigator + buildNext(preloadedNavigatorMatches, navigatorKey); + + _goHeroCache.putIfAbsent( + navigatorKey, () => _getHeroController(context)); + } + return _buildNavigator( pagePopContext.onPopPage, keyToPages[navigatorKey]!, @@ -302,7 +295,6 @@ class RouteBuilder { observers: observers ?? const [], restorationScopeId: restorationScopeId, heroController: _goHeroCache[navigatorKey], - //heroController: heroController, requestFocus: requestFocus, ); } @@ -313,7 +305,6 @@ class RouteBuilder { routerState: state, navigatorKey: shellNavigatorKey, routeMatchList: matchList, - preloadedMatchLists: preloadableNavigators, navigatorBuilder: buildShellNavigator, ); diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index ff8b4dbc91ca..9b27a0992629 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -55,10 +55,12 @@ typedef StatefulShellRoutePageBuilder = Page Function( /// Signature for functions used to build Navigators typedef NavigatorBuilder = Widget Function( - GlobalKey navigatorKey, - List? observers, - String? restorationScopeId, - ); + GlobalKey navigatorKey, + List? observers, + String? restorationScopeId, { + bool requestFocus, + RouteMatchList? preloadedNavigatorMatches, +}); /// The base class for [GoRoute] and [ShellRoute]. /// @@ -437,12 +439,6 @@ abstract class ShellRouteBase extends RouteBase { /// immediate sub-route of this shell route. GlobalKey navigatorKeyForSubRoute(RouteBase subRoute); - /// Returns the locations for any [Navigator]s of this shell route that are - /// eligible for preloading. - Map, RouteMatchList> preloadableNavigatorLocations( - RouteConfiguration routeConfiguration) => - , RouteMatchList>{}; - /// Returns the keys for the [Navigator]s associated with this shell route. Iterable> get _navigatorKeys => >[]; @@ -464,7 +460,6 @@ class ShellRouteContext { required this.routerState, required this.navigatorKey, required this.routeMatchList, - required this.preloadedMatchLists, required this.navigatorBuilder, }); @@ -482,10 +477,6 @@ class ShellRouteContext { /// within the associated [route]. final RouteMatchList routeMatchList; - /// The route match lists representing the locations of any additional - /// preloaded [Navigator]s within the associated [route]. - final Map, RouteMatchList> preloadedMatchLists; - /// Function used to build the [Navigator] for the current route. final NavigatorBuilder navigatorBuilder; } @@ -765,7 +756,7 @@ class StatefulShellRoute extends ShellRouteBase { assert(_debugValidateParentNavigatorKeys(branches)), assert(_debugValidateRestorationScopeIds(restorationScopeId, branches)), _preloadableBranches = branches - .where((StatefulShellBranch b) => b._shouldPreload) + .where((StatefulShellBranch b) => b.preload) .toList(growable: false), super._(routes: _routes(branches)); @@ -881,38 +872,44 @@ class StatefulShellRoute extends ShellRouteBase { } @override - Map, RouteMatchList> preloadableNavigatorLocations( - RouteConfiguration routeConfiguration) { - // Return quickly if there are no preloadable branches. - if (_preloadableBranches.isEmpty) { - return , RouteMatchList>{}; - } + Iterable> get _navigatorKeys => + branches.map((StatefulShellBranch b) => b.navigatorKey); - final Map, RouteMatchList> preloadable = - , RouteMatchList>{}; + StatefulNavigationShell _createShell( + BuildContext context, ShellRouteContext shellRouteContext) { + final GoRouter router = GoRouter.of(context); + final List<_PreloadedStatefulShellBranch> preloadData = + _preloadBranches(shellRouteContext, router); + return StatefulNavigationShell._( + shellRouteContext: shellRouteContext, + router: router, + containerBuilder: navigatorContainerBuilder, + preloadedBranches: preloadData, + ); + } + + List<_PreloadedStatefulShellBranch> _preloadBranches( + ShellRouteContext shellRouteContext, GoRouter router) { + final List<_PreloadedStatefulShellBranch> preloadData = + <_PreloadedStatefulShellBranch>[]; final StatefulNavigationShellState? shellState = _shellStateKey.currentState; for (final StatefulShellBranch branch in _preloadableBranches) { final bool loaded = shellState?._isBranchLoaded(branch) ?? false; if (!loaded) { - preloadable[branch.navigatorKey] = routeConfiguration.findMatch( - branch._effectiveInitialBranchLocation(routeConfiguration)); + final RouteMatchList matchList = router.configuration.findMatch( + branch._effectiveInitialBranchLocation(router.configuration)); + final Widget navigator = shellRouteContext.navigatorBuilder( + branch.navigatorKey, branch.observers, branch.restorationScopeId, + preloadedNavigatorMatches: matchList); + preloadData + .add(_PreloadedStatefulShellBranch(branch, matchList, navigator)); } } - return preloadable; - } - - @override - Iterable> get _navigatorKeys => - branches.map((StatefulShellBranch b) => b.navigatorKey); - StatefulNavigationShell _createShell( - BuildContext context, ShellRouteContext shellRouteContext) => - StatefulNavigationShell( - shellRouteContext: shellRouteContext, - router: GoRouter.of(context), - containerBuilder: navigatorContainerBuilder); + return preloadData; + } static Widget _indexedStackContainerBuilder(BuildContext context, StatefulNavigationShell navigationShell, List children) { @@ -964,6 +961,15 @@ class StatefulShellRoute extends ShellRouteBase { } } +/// Data structure used for preloading a [StatefulShellBranch]. +class _PreloadedStatefulShellBranch { + _PreloadedStatefulShellBranch(this.branch, this.matchList, this.navigator); + + final StatefulShellBranch branch; + final RouteMatchList matchList; + final Widget navigator; +} + /// Representation of a separate branch in a stateful navigation tree, used to /// configure [StatefulShellRoute]. /// @@ -988,7 +994,7 @@ class StatefulShellBranch { this.initialLocation, this.restorationScopeId, this.observers, - this.lazy = true, + this.preload = false, }) : navigatorKey = navigatorKey ?? GlobalKey() { assert(() { ShellRouteBase._debugCheckSubRouteParentNavigatorKeys( @@ -1026,10 +1032,10 @@ class StatefulShellBranch { final List? observers; /// Whether this route branch should be loaded only when navigating to it for - /// the first time (the default behavior, i.e. 'true'), or if it should be + /// the first time (the default behavior, i.e. 'false'), or if it should be /// eagerly loaded (preloaded). /// - /// If this property is false, the branch will be loaded immediately when the + /// If this property is true, the branch will be loaded immediately when the /// associated [StatefulShellRoute] is visited for the first time. In that /// case, the branch will be preloaded by navigating to the initial location /// (see [initialLocation]). @@ -1039,7 +1045,7 @@ class StatefulShellBranch { /// preparing the UI for animated transitions etc. Care must be taken to /// **keep the preloading to an absolute minimum** to avoid any unnecessary /// resource use. - final bool lazy; + final bool preload; /// The default route of this branch, i.e. the first descendant [GoRoute]. /// @@ -1048,8 +1054,6 @@ class StatefulShellBranch { GoRoute? get defaultRoute => RouteBase.routesRecursively(routes).whereType().firstOrNull; - bool get _shouldPreload => !lazy; - String _effectiveInitialBranchLocation( RouteConfiguration routeConfiguration) { if (initialLocation != null) { @@ -1088,12 +1092,25 @@ typedef ShellNavigationContainerBuilder = Widget Function(BuildContext context, /// ``` class StatefulNavigationShell extends StatefulWidget { /// Constructs an [StatefulNavigationShell]. + // TODO(tolo): There shouldn't be a need for a public constructor on StatefulNavigationShell, consider removing it (technically a breaking change). StatefulNavigationShell({ + required ShellRouteContext shellRouteContext, + required GoRouter router, + required ShellNavigationContainerBuilder containerBuilder, + }) : this._( + shellRouteContext: shellRouteContext, + router: router, + containerBuilder: containerBuilder, + preloadedBranches: <_PreloadedStatefulShellBranch>[]); + + StatefulNavigationShell._({ required this.shellRouteContext, required GoRouter router, required this.containerBuilder, + required List<_PreloadedStatefulShellBranch> preloadedBranches, }) : assert(shellRouteContext.route is StatefulShellRoute), _router = router, + _preloadedBranches = preloadedBranches, currentIndex = _indexOfBranchNavigatorKey( shellRouteContext.route as StatefulShellRoute, shellRouteContext.navigatorKey), @@ -1105,6 +1122,8 @@ class StatefulNavigationShell extends StatefulWidget { /// current [StatefulShellBranch]. final ShellRouteContext shellRouteContext; + final List<_PreloadedStatefulShellBranch> _preloadedBranches; + final GoRouter _router; /// The builder for a custom container for shell route Navigators. @@ -1253,6 +1272,8 @@ class StatefulNavigationShellState extends State } void _updateCurrentBranchStateFromWidget() { + _preloadBranches(); + final StatefulShellBranch branch = route.branches[widget.currentIndex]; final ShellRouteContext shellRouteContext = widget.shellRouteContext; final RouteMatchList currentBranchLocation = @@ -1273,21 +1294,14 @@ class StatefulNavigationShellState extends State shellRouteContext.navigatorBuilder( branch.navigatorKey, branch.observers, branch.restorationScopeId); } - - _preloadBranches(); } void _preloadBranches() { - final ShellRouteContext shellRouteContext = widget.shellRouteContext; - final Map, RouteMatchList> preloadedMatchLists = - shellRouteContext.preloadedMatchLists; - for (final StatefulShellBranch branch in route.branches) { - final RouteMatchList? matches = preloadedMatchLists[branch.navigatorKey]; - if (matches != null && !_isBranchLoaded(branch)) { - _branchLocation(branch, false).value = matches; - _branchNavigators[branch.navigatorKey] = - shellRouteContext.navigatorBuilder(branch.navigatorKey, - branch.observers, branch.restorationScopeId); + for (final _PreloadedStatefulShellBranch preloaded + in widget._preloadedBranches) { + if (!_isBranchLoaded(preloaded.branch)) { + _branchLocation(preloaded.branch, false).value = preloaded.matchList; + _branchNavigators[preloaded.branch.navigatorKey] = preloaded.navigator; } } } diff --git a/packages/go_router/test/go_router_test.dart b/packages/go_router/test/go_router_test.dart index 6be3f146ceea..eeb2247a45fe 100644 --- a/packages/go_router/test/go_router_test.dart +++ b/packages/go_router/test/go_router_test.dart @@ -3931,7 +3931,7 @@ void main() { builder: mockStackedShellBuilder, branches: [ StatefulShellBranch( - lazy: false, + preload: true, routes: [ GoRoute( path: '/c', @@ -3941,7 +3941,7 @@ void main() { ], ), StatefulShellBranch( - lazy: false, + preload: true, routes: [ GoRoute( path: '/d', @@ -3951,7 +3951,7 @@ void main() { ], ), StatefulShellBranch( - lazy: false, + preload: true, initialLocation: '/e/details', routes: [ GoRoute( @@ -4007,19 +4007,19 @@ void main() { builder: mockStackedShellBuilder, branches: [ StatefulShellBranch( - lazy: false, + preload: true, routes: [ StatefulShellRoute.indexedStack( builder: mockStackedShellBuilder, branches: [ - StatefulShellBranch(lazy: false, routes: [ + StatefulShellBranch(preload: true, routes: [ GoRoute( path: '/a', builder: (BuildContext context, GoRouterState state) => DummyStatefulWidget(key: statefulWidgetKeyA), ), ]), - StatefulShellBranch(lazy: false, routes: [ + StatefulShellBranch(preload: true, routes: [ GoRoute( path: '/b', builder: (BuildContext context, GoRouterState state) => @@ -4031,7 +4031,7 @@ void main() { ], ), StatefulShellBranch( - lazy: false, + preload: true, routes: [ GoRoute( path: '/c',