From 9c294efb9eb4646336f18696ab2c1f3fe13c7bba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20L=C3=B6fstrand?= Date: Tue, 12 Nov 2024 18:18:08 +0100 Subject: [PATCH] [go_router] Add support for preloading branches of StatefulShellRoute (revised solution) (#6467) Adds support for preloading branches in a `StatefulShellRoute`. This functionality was initially part of an early implementation of flutter/packages#2650, however it was decided to implement this in a separate PR. The current implementation is a rewrite of the original implementation to better fit the final version of `StatefulShellRoute` (and go_router in general). **NOTE**: this is a revised version of the initial solution (see flutter/packages#4251), containing a substantially simpler implementation made possible thanks to recent refactoring in go_router. This fixes issue flutter/flutter#127804. --- packages/go_router/CHANGELOG.md | 4 + packages/go_router/doc/configuration.md | 2 + .../others/custom_stateful_shell_route.dart | 87 ++++--- .../example/lib/stateful_shell_route.dart | 2 + packages/go_router/lib/src/builder.dart | 14 +- packages/go_router/lib/src/route.dart | 167 +++++++++--- packages/go_router/pubspec.yaml | 2 +- packages/go_router/test/go_router_test.dart | 241 ++++++++++++++++++ .../go_router/test/routing_config_test.dart | 114 +++++++++ 9 files changed, 566 insertions(+), 67 deletions(-) diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index 78abc879a9e9..edd933b41e2e 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,7 @@ +## 14.5.0 + +- Adds preload support to StatefulShellRoute, configurable via `preload` parameter on StatefulShellBranch. + ## 14.4.1 - Adds `missing_code_block_language_in_doc_comment` lint. diff --git a/packages/go_router/doc/configuration.md b/packages/go_router/doc/configuration.md index 6c4f1ec6ee4d..eb927805c9e8 100644 --- a/packages/go_router/doc/configuration.md +++ b/packages/go_router/doc/configuration.md @@ -192,6 +192,8 @@ branches: [ ], ), ], + // To enable preloading of the initial locations of branches, pass + // 'true' for the parameter `preload` (false is default). ), ``` 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 49c040cf2a63..dfacac830f30 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 @@ -11,6 +11,13 @@ final GlobalKey _rootNavigatorKey = GlobalKey(debugLabel: 'root'); final GlobalKey _tabANavigatorKey = GlobalKey(debugLabel: 'tabANav'); +final GlobalKey _tabBNavigatorKey = + GlobalKey(debugLabel: 'tabBNav'); +final GlobalKey _tabB1NavigatorKey = + GlobalKey(debugLabel: 'tabB1Nav'); +final GlobalKey _tabB2NavigatorKey = + GlobalKey(debugLabel: 'tabB2Nav'); + @visibleForTesting // ignore: public_member_api_docs final GlobalKey tabbedRootScreenKey = @@ -87,11 +94,15 @@ class NestedTabNavigationExampleApp extends StatelessWidget { // The route branch for the second tab of the bottom navigation bar. StatefulShellBranch( + navigatorKey: _tabBNavigatorKey, + // To enable preloading of the initial locations of branches, pass + // `true` for the parameter `preload` (`false` is default). + 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 // parameter. - // defaultLocation: '/b2', + // defaultLocation: '/b1', routes: [ StatefulShellRoute( builder: (BuildContext context, GoRouterState state, @@ -119,44 +130,53 @@ class NestedTabNavigationExampleApp extends StatelessWidget { // 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 + // `preload` to `true` (`false` is default). + preload: true, + 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, + ), + ), + ], ), - ], - ), - ]), + ]), ], ), ], @@ -619,6 +639,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/example/lib/stateful_shell_route.dart b/packages/go_router/example/lib/stateful_shell_route.dart index e6f0e7a1c0c6..96671556b03d 100644 --- a/packages/go_router/example/lib/stateful_shell_route.dart +++ b/packages/go_router/example/lib/stateful_shell_route.dart @@ -63,6 +63,8 @@ class NestedTabNavigationExampleApp extends StatelessWidget { ], ), ], + // To enable preloading of the initial locations of branches, pass + // 'true' for the parameter `preload` (false is default). ), // #enddocregion configuration-branches diff --git a/packages/go_router/lib/src/builder.dart b/packages/go_router/lib/src/builder.dart index 01c9b8c21977..4258bc7ec83b 100644 --- a/packages/go_router/lib/src/builder.dart +++ b/packages/go_router/lib/src/builder.dart @@ -110,6 +110,8 @@ class RouteBuilder { return builderWithNav( context, _CustomNavigator( + // The state needs to persist across rebuild. + key: GlobalObjectKey(configuration.navigatorKey.hashCode), navigatorKey: configuration.navigatorKey, observers: observers, navigatorRestorationId: restorationScopeId, @@ -271,16 +273,22 @@ class _CustomNavigatorState extends State<_CustomNavigator> { route: match.route, routerState: state, navigatorKey: navigatorKey, + match: match, routeMatchList: widget.matchList, - navigatorBuilder: - (List? observers, String? restorationScopeId) { + navigatorBuilder: ( + GlobalKey navigatorKey, + ShellRouteMatch match, + RouteMatchList matchList, + 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, + matchList: matchList, configuration: widget.configuration, observers: observers ?? const [], onPopPageWithRouteMatch: widget.onPopPageWithRouteMatch, diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index a5dcc734d054..c22a33170c0c 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -57,7 +57,11 @@ typedef StatefulShellRoutePageBuilder = Page Function( /// Signature for functions used to build Navigators typedef NavigatorBuilder = Widget Function( - List? observers, String? restorationScopeId); + GlobalKey navigatorKey, + ShellRouteMatch match, + RouteMatchList matchList, + List? observers, + String? restorationScopeId); /// Signature for function used in [RouteBase.onExit]. /// @@ -511,6 +515,7 @@ class ShellRouteContext { required this.route, required this.routerState, required this.navigatorKey, + required this.match, required this.routeMatchList, required this.navigatorBuilder, }); @@ -525,12 +530,22 @@ class ShellRouteContext { /// [route]. final GlobalKey navigatorKey; + /// The `ShellRouteMatch` in [routeMatchList] that corresponds to the + /// associated shell route. + final ShellRouteMatch match; + /// The route match list representing the current location within the /// associated shell route. final RouteMatchList routeMatchList; /// Function used to build the [Navigator] for the current route. final NavigatorBuilder navigatorBuilder; + + Widget _buildNavigatorForCurrentRoute( + List? observers, String? restorationScopeId) { + return navigatorBuilder( + navigatorKey, match, routeMatchList, observers, restorationScopeId); + } } /// A route that displays a UI shell around the matching child route. @@ -669,8 +684,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._buildNavigatorForCurrentRoute( + observers, restorationScopeId); return builder!(context, state, navigator); } return null; @@ -680,8 +695,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._buildNavigatorForCurrentRoute( + observers, restorationScopeId); return pageBuilder!(context, state, navigator); } return null; @@ -800,6 +815,7 @@ class StatefulShellRoute extends ShellRouteBase { required this.navigatorContainerBuilder, super.parentNavigatorKey, this.restorationScopeId, + GlobalKey? key, }) : assert(branches.isNotEmpty), assert((pageBuilder != null) || (builder != null), 'One of builder or pageBuilder must be provided'), @@ -807,6 +823,7 @@ class StatefulShellRoute extends ShellRouteBase { 'Navigator keys must be unique'), assert(_debugValidateParentNavigatorKeys(branches)), assert(_debugValidateRestorationScopeIds(restorationScopeId, branches)), + _shellStateKey = key ?? GlobalKey(), super._(routes: _routes(branches)); /// Constructs a StatefulShellRoute that uses an [IndexedStack] for its @@ -826,6 +843,7 @@ class StatefulShellRoute extends ShellRouteBase { GlobalKey? parentNavigatorKey, StatefulShellRoutePageBuilder? pageBuilder, String? restorationScopeId, + GlobalKey? key, }) : this( branches: branches, redirect: redirect, @@ -834,6 +852,7 @@ class StatefulShellRoute extends ShellRouteBase { parentNavigatorKey: parentNavigatorKey, restorationScopeId: restorationScopeId, navigatorContainerBuilder: _indexedStackContainerBuilder, + key: key, ); /// Restoration ID to save and restore the state of the navigator, including @@ -889,8 +908,7 @@ class StatefulShellRoute extends ShellRouteBase { /// [StatefulShellBranch.navigatorKey]. final List branches; - final GlobalKey _shellStateKey = - GlobalKey(); + final GlobalKey _shellStateKey; @override Widget? buildWidget(BuildContext context, GoRouterState state, @@ -1003,6 +1021,7 @@ class StatefulShellBranch { this.initialLocation, this.restorationScopeId, this.observers, + this.preload = false, }) : navigatorKey = navigatorKey ?? GlobalKey() { assert(() { ShellRouteBase._debugCheckSubRouteParentNavigatorKeys( @@ -1039,6 +1058,21 @@ class StatefulShellBranch { /// The observers parameter is used by the [Navigator] built for this branch. final List? observers; + /// Whether this route branch should be eagerly loaded when navigating to the + /// associated StatefulShellRoute for the first time. + /// + /// If this property is `false` (the default), the branch will only be loaded + /// when needed. Set the value to `true` to force the branch to 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. As with all preloading, there is a + /// cost in terms of resource use. **Use sparingly** and only after a thorough + /// trade-off analysis. + final bool preload; + /// 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 @@ -1124,12 +1158,19 @@ class StatefulNavigationShell extends StatefulWidget { } } + /// Checks if the provided branch is loaded (i.e. has navigation state + /// associated with it). + @visibleForTesting + List get debugLoadedBranches => + route._shellStateKey.currentState?._loadedBranches ?? + []; + /// Gets the effective initial location for the branch at the provided index /// in the associated [StatefulShellRoute]. /// /// The effective initial location is either the - /// [StackedShellBranch.initialLocation], if specified, or the location of the - /// [StackedShellBranch.defaultRoute]. + /// [StatefulShellBranch.initialLocation], if specified, or the location of the + /// [StatefulShellBranch.defaultRoute]. String _effectiveInitialBranchLocation(int index) { final StatefulShellRoute route = shellRouteContext.route as StatefulShellRoute; @@ -1182,15 +1223,18 @@ class StatefulNavigationShell extends StatefulWidget { /// State for StatefulNavigationShell. class StatefulNavigationShellState extends State with RestorationMixin { - final Map _branchNavigators = {}; + final Map _branchState = + {}; /// The associated [StatefulShellRoute]. StatefulShellRoute get route => widget.route; GoRouter get _router => widget._router; - final Map _branchLocations = - {}; + bool _isBranchLoaded(StatefulShellBranch branch) => + _branchState[branch] != null; + + List get _loadedBranches => _branchState.keys.toList(); @override String? get restorationId => route.restorationScopeId; @@ -1204,21 +1248,21 @@ class StatefulNavigationShellState extends State : identityHashCode(branch).toString(); } - _RestorableRouteMatchList _branchLocation(StatefulShellBranch branch, + _StatefulShellBranchState _branchStateFor(StatefulShellBranch branch, [bool register = true]) { - return _branchLocations.putIfAbsent(branch, () { - final _RestorableRouteMatchList branchLocation = - _RestorableRouteMatchList(_router.configuration); + return _branchState.putIfAbsent(branch, () { + final _StatefulShellBranchState branchState = _StatefulShellBranchState( + location: _RestorableRouteMatchList(_router.configuration)); if (register) { registerForRestoration( - branchLocation, _branchLocationRestorationScopeId(branch)); + branchState.location, _branchLocationRestorationScopeId(branch)); } - return branchLocation; + return branchState; }); } RouteMatchList? _matchListForBranch(int index) => - _branchLocations[route.branches[index]]?.value; + _branchState[route.branches[index]]?.location.value; /// Creates a new RouteMatchList that is scoped to the Navigators of the /// current shell route or it's descendants. This involves removing all the @@ -1246,27 +1290,73 @@ class StatefulNavigationShellState extends State } void _updateCurrentBranchStateFromWidget() { + _preloadBranches(); + final StatefulShellBranch branch = route.branches[widget.currentIndex]; final ShellRouteContext shellRouteContext = widget.shellRouteContext; final RouteMatchList currentBranchLocation = _scopedMatchList(shellRouteContext.routeMatchList); - final _RestorableRouteMatchList branchLocation = - _branchLocation(branch, false); - final RouteMatchList previousBranchLocation = branchLocation.value; - branchLocation.value = currentBranchLocation; - final bool hasExistingNavigator = - _branchNavigators[branch.navigatorKey] != null; + final _StatefulShellBranchState branchState = + _branchStateFor(branch, false); + final RouteMatchList previousBranchLocation = branchState.location.value; + branchState.location.value = currentBranchLocation; + final bool hasExistingNavigator = branchState.navigator != null; /// Only update the Navigator of the route match list has changed final bool locationChanged = previousBranchLocation != currentBranchLocation; if (locationChanged || !hasExistingNavigator) { - _branchNavigators[branch.navigatorKey] = shellRouteContext - .navigatorBuilder(branch.observers, branch.restorationScopeId); + branchState.navigator = shellRouteContext._buildNavigatorForCurrentRoute( + branch.observers, branch.restorationScopeId); + } + + _cleanUpObsoleteBranches(); + } + + void _preloadBranches() { + for (int i = 0; i < route.branches.length; i++) { + final StatefulShellBranch branch = route.branches[i]; + if (i != currentIndex && branch.preload && !_isBranchLoaded(branch)) { + // Find the match for the current StatefulShellRoute in matchList + // returned by _effectiveInitialBranchLocation (the initial location + // should already have been validated by RouteConfiguration). + final RouteMatchList matchList = _router.configuration + .findMatch(Uri.parse(widget._effectiveInitialBranchLocation(i))); + ShellRouteMatch? match; + matchList.visitRouteMatches((RouteMatchBase e) { + match = e is ShellRouteMatch && e.route == route ? e : match; + return match == null; + }); + assert(match != null); + + final Widget navigator = widget.shellRouteContext.navigatorBuilder( + branch.navigatorKey, + match!, + matchList, + branch.observers, + branch.restorationScopeId, + ); + + final _StatefulShellBranchState branchState = + _branchStateFor(branch, false); + branchState.location.value = matchList; + branchState.navigator = navigator; + } } } + void _cleanUpObsoleteBranches() { + _branchState.removeWhere( + (StatefulShellBranch branch, _StatefulShellBranchState branchState) { + if (!route.branches.contains(branch)) { + branchState.dispose(); + return true; + } + return false; + }); + } + /// The index of the currently active [StatefulShellBranch]. /// /// Corresponds to the index in the branches field of [StatefulShellRoute]. @@ -1299,14 +1389,14 @@ class StatefulNavigationShellState extends State @override void dispose() { super.dispose(); - for (final StatefulShellBranch branch in route.branches) { - _branchLocations[branch]?.dispose(); + for (final _StatefulShellBranchState branchState in _branchState.values) { + branchState.dispose(); } } @override void restoreState(RestorationBucket? oldBucket, bool initialRestore) { - route.branches.forEach(_branchLocation); + route.branches.forEach(_branchStateFor); } @override @@ -1321,14 +1411,27 @@ class StatefulNavigationShellState extends State .map((StatefulShellBranch branch) => _BranchNavigatorProxy( key: ObjectKey(branch), branch: branch, - navigatorForBranch: (StatefulShellBranch b) => - _branchNavigators[b.navigatorKey])) + navigatorForBranch: (StatefulShellBranch branch) => + _branchState[branch]?.navigator)) .toList(); return widget.containerBuilder(context, widget, children); } } +class _StatefulShellBranchState { + _StatefulShellBranchState({ + required this.location, + }); + + Widget? navigator; + final _RestorableRouteMatchList location; + + void dispose() { + location.dispose(); + } +} + /// [RestorableProperty] for enabling state restoration of [RouteMatchList]s. class _RestorableRouteMatchList extends RestorableProperty { _RestorableRouteMatchList(RouteConfiguration configuration) diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index 7bfc3f9297f6..94c1899e3df0 100644 --- a/packages/go_router/pubspec.yaml +++ b/packages/go_router/pubspec.yaml @@ -1,7 +1,7 @@ name: go_router description: A declarative router for Flutter based on Navigation 2 supporting deep linking, data-driven routes and more -version: 14.4.1 +version: 14.5.0 repository: https://github.com/flutter/packages/tree/main/packages/go_router issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router%22 diff --git a/packages/go_router/test/go_router_test.dart b/packages/go_router/test/go_router_test.dart index f2c0a69bd267..77928e71aa3a 100644 --- a/packages/go_router/test/go_router_test.dart +++ b/packages/go_router/test/go_router_test.dart @@ -4357,6 +4357,179 @@ void main() { expect(find.text('Screen B'), 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( + preload: true, + routes: [ + GoRoute( + path: '/c', + builder: (BuildContext context, GoRouterState state) => + DummyStatefulWidget(key: statefulWidgetKeyC), + ), + ], + ), + StatefulShellBranch( + preload: true, + routes: [ + GoRoute( + path: '/d', + builder: (BuildContext context, GoRouterState state) => + DummyStatefulWidget(key: statefulWidgetKeyD), + ), + ], + ), + StatefulShellBranch( + preload: true, + 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( + preload: true, + routes: [ + StatefulShellRoute.indexedStack( + builder: mockStackedShellBuilder, + branches: [ + StatefulShellBranch(preload: true, routes: [ + GoRoute( + path: '/a', + builder: (BuildContext context, GoRouterState state) => + DummyStatefulWidget(key: statefulWidgetKeyA), + ), + ]), + StatefulShellBranch(preload: true, routes: [ + GoRoute( + path: '/b', + builder: (BuildContext context, GoRouterState state) => + DummyStatefulWidget(key: statefulWidgetKeyB), + ), + ]), + ], + ), + ], + ), + StatefulShellBranch( + preload: true, + 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 { @@ -4564,6 +4737,74 @@ void main() { expect(find.text('Top Modal'), findsNothing); expect(find.text('Nested Modal'), findsOneWidget); }); + + testWidgets( + 'Obsolete branches in StatefulShellRoute are cleaned up after route ' + 'configuration change', + // TODO(tolo): Temporarily skipped due to a bug that causes test to faiL + skip: true, (WidgetTester tester) async { + final GlobalKey rootNavigatorKey = + GlobalKey(debugLabel: 'root'); + final GlobalKey statefulShellKey = + GlobalKey(debugLabel: 'shell'); + StatefulNavigationShell? routeState; + StatefulShellBranch makeBranch(String name) => StatefulShellBranch( + navigatorKey: + GlobalKey(debugLabel: 'branch-$name'), + preload: true, + initialLocation: '/$name', + routes: [ + GoRoute( + path: '/$name', + builder: (BuildContext context, GoRouterState state) => + Text('Screen $name'), + ), + ]); + + List createRoutes(bool includeCRoute) => [ + StatefulShellRoute.indexedStack( + key: statefulShellKey, + builder: (BuildContext context, GoRouterState state, + StatefulNavigationShell navigationShell) { + routeState = navigationShell; + return navigationShell; + }, + branches: [ + makeBranch('a'), + makeBranch('b'), + if (includeCRoute) makeBranch('c'), + ], + ), + ]; + + final ValueNotifier config = ValueNotifier( + RoutingConfig(routes: createRoutes(true)), + ); + addTearDown(config.dispose); + await createRouterWithRoutingConfig( + navigatorKey: rootNavigatorKey, + config, + tester, + initialLocation: '/a', + errorBuilder: (_, __) => const Text('error'), + ); + await tester.pumpAndSettle(); + + bool hasLoadedBranch(String name) => routeState!.debugLoadedBranches + .any((StatefulShellBranch e) => e.initialLocation == '/$name'); + + expect(hasLoadedBranch('a'), isTrue); + expect(hasLoadedBranch('b'), isTrue); + expect(hasLoadedBranch('c'), isTrue); + + // Unload branch 'c' by changing the route configuration + config.value = RoutingConfig(routes: createRoutes(false)); + await tester.pumpAndSettle(); + + expect(hasLoadedBranch('a'), isTrue); + expect(hasLoadedBranch('b'), isTrue); + expect(hasLoadedBranch('c'), isFalse); + }); }); group('Imperative navigation', () { diff --git a/packages/go_router/test/routing_config_test.dart b/packages/go_router/test/routing_config_test.dart index 949c25934c66..b1b02a98fbba 100644 --- a/packages/go_router/test/routing_config_test.dart +++ b/packages/go_router/test/routing_config_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; @@ -111,6 +112,96 @@ void main() { expect(find.text('error'), findsOneWidget); }); + testWidgets('routing config works after routing changes case 3', + (WidgetTester tester) async { + final GlobalKey<_StatefulTestState> key = + GlobalKey<_StatefulTestState>(debugLabel: 'testState'); + final GlobalKey rootNavigatorKey = + GlobalKey(debugLabel: 'root'); + + final ValueNotifier config = ValueNotifier( + RoutingConfig( + routes: [ + GoRoute( + path: '/', + builder: (_, __) => + StatefulTest(key: key, child: const Text('home'))), + ], + ), + ); + addTearDown(config.dispose); + await createRouterWithRoutingConfig( + navigatorKey: rootNavigatorKey, + config, + tester, + errorBuilder: (_, __) => const Text('error'), + ); + expect(find.text('home'), findsOneWidget); + key.currentState!.value = 1; + + config.value = RoutingConfig( + routes: [ + GoRoute( + path: '/', + builder: (_, __) => + StatefulTest(key: key, child: const Text('home'))), + GoRoute(path: '/abc', builder: (_, __) => const Text('/abc')), + ], + ); + await tester.pumpAndSettle(); + expect(key.currentState!.value == 1, isTrue); + }); + + testWidgets('routing config works with shell route', + // TODO(tolo): Temporarily skipped due to a bug that causes test to faiL + skip: true, (WidgetTester tester) async { + final GlobalKey<_StatefulTestState> key = + GlobalKey<_StatefulTestState>(debugLabel: 'testState'); + final GlobalKey rootNavigatorKey = + GlobalKey(debugLabel: 'root'); + final GlobalKey shellNavigatorKey = + GlobalKey(debugLabel: 'shell'); + + final ValueNotifier config = ValueNotifier( + RoutingConfig( + routes: [ + ShellRoute( + navigatorKey: shellNavigatorKey, + routes: [ + GoRoute(path: '/', builder: (_, __) => const Text('home')), + ], + builder: (_, __, Widget widget) => + StatefulTest(key: key, child: widget)), + ], + ), + ); + addTearDown(config.dispose); + await createRouterWithRoutingConfig( + navigatorKey: rootNavigatorKey, + config, + tester, + errorBuilder: (_, __) => const Text('error'), + ); + expect(find.text('home'), findsOneWidget); + key.currentState!.value = 1; + + config.value = RoutingConfig( + routes: [ + ShellRoute( + navigatorKey: shellNavigatorKey, + routes: [ + GoRoute(path: '/', builder: (_, __) => const Text('home')), + GoRoute(path: '/abc', builder: (_, __) => const Text('/abc')), + ], + builder: (_, __, Widget widget) => + StatefulTest(key: key, child: widget)), + ], + ); + await tester.pumpAndSettle(); + + expect(key.currentState!.value == 1, isTrue); + }); + testWidgets('routing config works with named route', (WidgetTester tester) async { final ValueNotifier config = ValueNotifier( @@ -157,3 +248,26 @@ void main() { expect(find.text('def'), findsOneWidget); }); } + +class StatefulTest extends StatefulWidget { + const StatefulTest({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _StatefulTestState(); +} + +class _StatefulTestState extends State { + int value = 0; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + widget.child, + Text('State: $value'), + ], + ); + } +}