From 6be9a5d320a07249fde49cd868b40ccb036e4021 Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Sun, 22 Dec 2024 16:22:07 +0200 Subject: [PATCH 1/5] [go_router] Added top level onEnter callback. Added onEnter callback to enable route interception and demonstrate usage in example. --- packages/go_router/CHANGELOG.md | 3 +- .../example/lib/top_level_on_enter.dart | 154 ++++++++++++++++++ packages/go_router/lib/src/configuration.dart | 33 ++++ packages/go_router/lib/src/parser.dart | 153 +++++++++++------ packages/go_router/lib/src/router.dart | 6 + packages/go_router/test/parser_test.dart | 54 ++++++ 6 files changed, 356 insertions(+), 47 deletions(-) create mode 100644 packages/go_router/example/lib/top_level_on_enter.dart diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index 9327b97442bb..77068dffb830 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,6 +1,7 @@ ## NEXT -* Updates minimum supported SDK version to Flutter 3.22/Dart 3.4. +* Updated the minimum supported SDK version to Flutter 3.22/Dart 3.4. +* Added new top level `onEnter` callback for controlling incoming route navigation. ## 14.6.2 diff --git a/packages/go_router/example/lib/top_level_on_enter.dart b/packages/go_router/example/lib/top_level_on_enter.dart new file mode 100644 index 000000000000..011af13c0a5a --- /dev/null +++ b/packages/go_router/example/lib/top_level_on_enter.dart @@ -0,0 +1,154 @@ +// Copyright 2013 The Flutter Authors. +// 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'; + +void main() => runApp(const App()); + +/// The main application widget. +class App extends StatelessWidget { + /// Constructs an [App]. + const App({super.key}); + + /// The title of the app. + static const String title = 'GoRouter Example: Top-level onEnter'; + + @override + Widget build(BuildContext context) => MaterialApp.router( + routerConfig: GoRouter( + initialLocation: '/home', + + /// A callback invoked for every route navigation attempt. + /// + /// If the callback returns `false`, the navigation is blocked. + /// Use this to handle authentication, referrals, or other route-based logic. + onEnter: (BuildContext context, GoRouterState state) { + // Save the referral code (if provided) and block navigation to the /referral route. + if (state.uri.path == '/referral') { + saveReferralCode(context, state.uri.queryParameters['code']); + return false; + } + + return true; // Allow navigation for all other routes. + }, + + /// The list of application routes. + routes: [ + GoRoute( + path: '/login', + builder: (BuildContext context, GoRouterState state) => + const LoginScreen(), + ), + GoRoute( + path: '/home', + builder: (BuildContext context, GoRouterState state) => + const HomeScreen(), + ), + GoRoute( + path: '/settings', + builder: (BuildContext context, GoRouterState state) => + const SettingsScreen(), + ), + ], + ), + title: title, + ); +} + +/// The login screen widget. +class LoginScreen extends StatelessWidget { + /// Constructs a [LoginScreen]. + const LoginScreen({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text(App.title)), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () => context.go('/home'), + child: const Text('Go to Home'), + ), + ElevatedButton( + onPressed: () => context.go('/settings'), + child: const Text('Go to Settings'), + ), + ], + ), + ), + ); +} + +/// The home screen widget. +class HomeScreen extends StatelessWidget { + /// Constructs a [HomeScreen]. + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text(App.title)), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () => context.go('/login'), + child: const Text('Go to Login'), + ), + ElevatedButton( + onPressed: () => context.go('/settings'), + child: const Text('Go to Settings'), + ), + ElevatedButton( + // This would typically be triggered by an incoming deep link. + onPressed: () => context.go('/referral?code=12345'), + child: const Text('Save Referral Code'), + ), + ], + ), + ), + ); +} + +/// The settings screen widget. +class SettingsScreen extends StatelessWidget { + /// Constructs a [SettingsScreen]. + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text(App.title)), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () => context.go('/login'), + child: const Text('Go to Login'), + ), + ElevatedButton( + onPressed: () => context.go('/home'), + child: const Text('Go to Home'), + ), + ], + ), + ), + ); +} + +/// Saves a referral code. +/// +/// Displays a [SnackBar] with the referral code for demonstration purposes. +/// Replace this with real referral handling logic. +void saveReferralCode(BuildContext context, String? code) { + if (code != null) { + // Here you can implement logic to save the referral code as needed. + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Referral code saved: $code')), + ); + } +} diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index cc671066218d..acf6f86271d1 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -20,6 +20,9 @@ import 'state.dart'; typedef GoRouterRedirect = FutureOr Function( BuildContext context, GoRouterState state); +/// The signature of the onEnter callback. +typedef OnEnter = bool Function(BuildContext context, GoRouterState state); + /// The route configuration for GoRouter configured by the app. class RouteConfiguration { /// Constructs a [RouteConfiguration]. @@ -27,6 +30,7 @@ class RouteConfiguration { this._routingConfig, { required this.navigatorKey, this.extraCodec, + this.onEnter, }) { _onRoutingTableChanged(); _routingConfig.addListener(_onRoutingTableChanged); @@ -246,6 +250,35 @@ class RouteConfiguration { /// example. final Codec? extraCodec; + /// A callback invoked for every incoming route before it is processed. + /// + /// This callback allows you to control navigation by inspecting the incoming + /// route and conditionally preventing the navigation. If the callback returns + /// `true`, the GoRouter proceeds with the regular navigation and redirection + /// logic. If the callback returns `false`, the navigation is canceled. + /// + /// When a deep link opens the app and `onEnter` returns `false`, GoRouter + /// will automatically redirect to the initial route or '/'. + /// + /// Example: + /// ```dart + /// final GoRouter router = GoRouter( + /// routes: [...], + /// onEnter: (BuildContext context, Uri uri) { + /// if (uri.path == '/login' && isUserLoggedIn()) { + /// return false; // Prevent navigation to /login + /// } + /// if (uri.path == '/referral') { + /// // Save the referral code and prevent navigation + /// saveReferralCode(uri.queryParameters['code']); + /// return false; + /// } + /// return true; // Allow navigation + /// }, + /// ); + /// ``` + final OnEnter? onEnter; + final Map _nameToPath = {}; /// Looks up the url location by a [GoRoute]'s name. diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index d1981898a8bb..af3c99329e90 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -1,3 +1,4 @@ +// ignore_for_file: use_build_context_synchronously // 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. @@ -8,11 +9,9 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -import 'configuration.dart'; -import 'information_provider.dart'; +import '../go_router.dart'; import 'logging.dart'; import 'match.dart'; -import 'router.dart'; /// The function signature of [GoRouteInformationParser.onParserException]. /// @@ -32,8 +31,10 @@ class GoRouteInformationParser extends RouteInformationParser { /// Creates a [GoRouteInformationParser]. GoRouteInformationParser({ required this.configuration, + required String? initialLocation, required this.onParserException, - }) : _routeMatchListCodec = RouteMatchListCodec(configuration); + }) : _routeMatchListCodec = RouteMatchListCodec(configuration), + _initialLocation = initialLocation; /// The route configuration used for parsing [RouteInformation]s. final RouteConfiguration configuration; @@ -45,8 +46,10 @@ class GoRouteInformationParser extends RouteInformationParser { final ParserExceptionHandler? onParserException; final RouteMatchListCodec _routeMatchListCodec; + final String? _initialLocation; - final Random _random = Random(); + // Store the last successful match list so we can truly "stay" on the same route. + RouteMatchList? _lastMatchList; /// The future of current route parsing. /// @@ -54,81 +57,129 @@ class GoRouteInformationParser extends RouteInformationParser { @visibleForTesting Future? debugParserFuture; + final Random _random = Random(); + /// Called by the [Router]. The @override Future parseRouteInformationWithDependencies( RouteInformation routeInformation, BuildContext context, ) { - assert(routeInformation.state != null); - final Object state = routeInformation.state!; + // 1) Defensive check: if we get a null state, just return empty (unlikely). + if (routeInformation.state == null) { + return SynchronousFuture(RouteMatchList.empty); + } - if (state is! RouteInformationState) { - // This is a result of browser backward/forward button or state - // restoration. In this case, the route match list is already stored in - // the state. + final Object infoState = routeInformation.state!; + + // 2) If state is not RouteInformationState => typically browser nav or state restoration + // => decode an existing match from the saved Map. + if (infoState is! RouteInformationState) { final RouteMatchList matchList = - _routeMatchListCodec.decode(state as Map); - return debugParserFuture = _redirect(context, matchList) - .then((RouteMatchList value) { + _routeMatchListCodec.decode(infoState as Map); + + return debugParserFuture = + _redirect(context, matchList).then((RouteMatchList value) { if (value.isError && onParserException != null) { - // TODO(chunhtai): Figure out what to return if context is invalid. - // ignore: use_build_context_synchronously return onParserException!(context, value); } + _lastMatchList = value; // store after success return value; }); } + // 3) If there's an `onEnter` callback, let's see if we want to short-circuit. + // (Note that .host.isNotEmpty check is optional — depends on your scenario.) + + if (configuration.onEnter != null) { + final RouteMatchList onEnterMatches = configuration.findMatch( + routeInformation.uri, + extra: infoState.extra, + ); + + final GoRouterState state = + configuration.buildTopLevelGoRouterState(onEnterMatches); + + final bool canEnter = configuration.onEnter!( + context, + state, + ); + + if (!canEnter) { + // The user "handled" the deep link => do NOT navigate. + // Return our *last known route* if possible. + if (_lastMatchList != null) { + return SynchronousFuture(_lastMatchList!); + } else { + // Fallback if we've never parsed a route before: + final Uri defaultUri = Uri.parse(_initialLocation ?? '/'); + final RouteMatchList fallbackMatches = configuration.findMatch( + defaultUri, + extra: infoState.extra, + ); + _lastMatchList = fallbackMatches; + return SynchronousFuture(fallbackMatches); + } + } + } + + // 4) Otherwise, do normal route matching: Uri uri = routeInformation.uri; if (uri.hasEmptyPath) { uri = uri.replace(path: '/'); } else if (uri.path.length > 1 && uri.path.endsWith('/')) { - // Remove trailing `/`. uri = uri.replace(path: uri.path.substring(0, uri.path.length - 1)); } + final RouteMatchList initialMatches = configuration.findMatch( uri, - extra: state.extra, + extra: infoState.extra, ); if (initialMatches.isError) { log('No initial matches: ${routeInformation.uri.path}'); } - return debugParserFuture = _redirect( - context, - initialMatches, - ).then((RouteMatchList matchList) { + // 5) Possibly do a redirect: + return debugParserFuture = + _redirect(context, initialMatches).then((RouteMatchList matchList) { + // If error, call parser exception if any if (matchList.isError && onParserException != null) { - // TODO(chunhtai): Figure out what to return if context is invalid. - // ignore: use_build_context_synchronously return onParserException!(context, matchList); } + // 6) Check for redirect-only route leftover assert(() { if (matchList.isNotEmpty) { - assert(!matchList.last.route.redirectOnly, - 'A redirect-only route must redirect to location different from itself.\n The offending route: ${matchList.last.route}'); + assert( + !matchList.last.route.redirectOnly, + 'A redirect-only route must redirect to a different location.\n' + 'Offending route: ${matchList.last.route}'); } return true; }()); - return _updateRouteMatchList( + + // 7) If it's a push/replace etc., handle that + final RouteMatchList updated = _updateRouteMatchList( matchList, - baseRouteMatchList: state.baseRouteMatchList, - completer: state.completer, - type: state.type, + baseRouteMatchList: infoState.baseRouteMatchList, + completer: infoState.completer, + type: infoState.type, ); + + // 8) Save as our "last known good" config + _lastMatchList = updated; + return updated; }); } @override Future parseRouteInformation( RouteInformation routeInformation) { + // Not used in go_router, so we can unimplement or throw: throw UnimplementedError( - 'use parseRouteInformationWithDependencies instead'); + 'Use parseRouteInformationWithDependencies instead'); } - /// for use by the Router architecture as part of the RouteInformationParser @override RouteInformation? restoreRouteInformation(RouteMatchList configuration) { if (configuration.isEmpty) { @@ -139,7 +190,6 @@ class GoRouteInformationParser extends RouteInformationParser { (configuration.matches.last is ImperativeRouteMatch || configuration.matches.last is ShellRouteMatch)) { RouteMatchBase route = configuration.matches.last; - while (route is! ImperativeRouteMatch) { if (route is ShellRouteMatch && route.matches.isNotEmpty) { route = route.matches.last; @@ -147,7 +197,6 @@ class GoRouteInformationParser extends RouteInformationParser { break; } } - if (route case final ImperativeRouteMatch safeRoute) { location = safeRoute.matches.uri.toString(); } @@ -158,16 +207,22 @@ class GoRouteInformationParser extends RouteInformationParser { ); } + // Just calls configuration.redirect, wrapped in synchronous future if needed. Future _redirect( - BuildContext context, RouteMatchList routeMatch) { - final FutureOr redirectedFuture = configuration - .redirect(context, routeMatch, redirectHistory: []); - if (redirectedFuture is RouteMatchList) { - return SynchronousFuture(redirectedFuture); + BuildContext context, RouteMatchList matchList) { + final FutureOr result = configuration.redirect( + context, + matchList, + redirectHistory: [], + ); + if (result is RouteMatchList) { + return SynchronousFuture(result); } - return redirectedFuture; + return result; } + // If the user performed push/pushReplacement, etc., we might wrap newMatches + // in ImperativeRouteMatches. RouteMatchList _updateRouteMatchList( RouteMatchList newMatchList, { required RouteMatchList? baseRouteMatchList, @@ -212,15 +267,21 @@ class GoRouteInformationParser extends RouteInformationParser { case NavigatingType.go: return newMatchList; case NavigatingType.restore: - // Still need to consider redirection. - return baseRouteMatchList!.uri.toString() != newMatchList.uri.toString() - ? newMatchList - : baseRouteMatchList; + // If the URIs differ, we might want the new one; if they're the same, + // keep the old. + if (baseRouteMatchList!.uri.toString() != newMatchList.uri.toString()) { + return newMatchList; + } else { + return baseRouteMatchList; + } } } ValueKey _getUniqueValueKey() { - return ValueKey(String.fromCharCodes( - List.generate(32, (_) => _random.nextInt(33) + 89))); + return ValueKey( + String.fromCharCodes( + List.generate(32, (_) => _random.nextInt(33) + 89), + ), + ); } } diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index 11f40f505cac..9cc856c20ace 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -122,6 +122,7 @@ class GoRouter implements RouterConfig { /// The `routes` must not be null and must contain an [GoRouter] to match `/`. factory GoRouter({ required List routes, + OnEnter? onEnter, Codec? extraCodec, GoExceptionHandler? onException, GoRouterPageBuilder? errorPageBuilder, @@ -146,6 +147,7 @@ class GoRouter implements RouterConfig { redirect: redirect ?? RoutingConfig._defaultRedirect, redirectLimit: redirectLimit), ), + onEnter: onEnter, extraCodec: extraCodec, onException: onException, errorPageBuilder: errorPageBuilder, @@ -169,6 +171,7 @@ class GoRouter implements RouterConfig { GoRouter.routingConfig({ required ValueListenable routingConfig, Codec? extraCodec, + OnEnter? onEnter, GoExceptionHandler? onException, GoRouterPageBuilder? errorPageBuilder, GoRouterWidgetBuilder? errorBuilder, @@ -206,6 +209,7 @@ class GoRouter implements RouterConfig { _routingConfig, navigatorKey: navigatorKey, extraCodec: extraCodec, + onEnter: onEnter, ); final ParserExceptionHandler? parserExceptionHandler; @@ -224,6 +228,7 @@ class GoRouter implements RouterConfig { routeInformationParser = GoRouteInformationParser( onParserException: parserExceptionHandler, configuration: configuration, + initialLocation: initialLocation, ); routeInformationProvider = GoRouteInformationProvider( @@ -565,6 +570,7 @@ class GoRouter implements RouterConfig { /// A routing config that is never going to change. class _ConstantRoutingConfig extends ValueListenable { const _ConstantRoutingConfig(this.value); + @override void addListener(VoidCallback listener) { // Intentionally empty because listener will never be called. diff --git a/packages/go_router/test/parser_test.dart b/packages/go_router/test/parser_test.dart index 9cb4aa2a071f..c7f1d1c443a8 100644 --- a/packages/go_router/test/parser_test.dart +++ b/packages/go_router/test/parser_test.dart @@ -636,4 +636,58 @@ void main() { expect(match.matches, hasLength(1)); expect(matchesObj.error, isNull); }); + + testWidgets( + 'GoRouteInformationParser short-circuits if onEnter returns false', + (WidgetTester tester) async { + bool onEnterCalled = false; + final GoRouter router = GoRouter( + // Provide a custom onEnter callback that always returns true. + onEnter: (BuildContext context, GoRouterState state) { + onEnterCalled = true; + return false; // Always prevent entering new uris. + }, + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const Placeholder(), + routes: [ + GoRoute(path: 'abc', builder: (_, __) => const Placeholder()), + ], + ), + ], + ); + addTearDown(router.dispose); + + // Pump the widget so the router is actually in the tree. + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + + // Grab the parser we want to test. + final GoRouteInformationParser parser = router.routeInformationParser; + + final BuildContext context = tester.element(find.byType(Router)); + // Save what we consider "old route" (the route we're currently on). + final RouteMatchList oldConfiguration = + router.routerDelegate.currentConfiguration; + + // Attempt to parse a new deep link: "/abc" + final RouteInformation routeInfo = RouteInformation( + uri: Uri.parse('/abc'), + state: RouteInformationState(type: NavigatingType.go), + ); + final RouteMatchList newMatch = + await parser.parseRouteInformationWithDependencies( + routeInfo, + context, + ); + + // Because our onEnter returned `true`, we expect we "did nothing." + // => Check that the parser short-circuited (did not produce a new route). + expect(onEnterCalled, isTrue, reason: 'onEnter was not called.'); + expect( + newMatch, + equals(oldConfiguration), + reason: 'Expected the parser to short-circuit and keep the old route.', + ); + }); } From 171b639a16a374ad5f6b1d8bf398b437369c5477 Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Sun, 22 Dec 2024 17:02:46 +0200 Subject: [PATCH 2/5] added version 14.7.0 --- packages/go_router/CHANGELOG.md | 6 +++--- packages/go_router/pubspec.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index 77068dffb830..8fc8c12bee6c 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,7 +1,7 @@ -## NEXT +## 14.7.0 -* Updated the minimum supported SDK version to Flutter 3.22/Dart 3.4. -* Added new top level `onEnter` callback for controlling incoming route navigation. +- Updated the minimum supported SDK version to Flutter 3.22/Dart 3.4. +- Added new top level `onEnter` callback for controlling incoming route navigation. ## 14.6.2 diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index 1113c4cbea93..a7a9e866d125 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.6.2 +version: 14.7.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 d1e1fc2b0b82b4138eff567e3a135161aa71e980 Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Fri, 24 Jan 2025 11:48:13 +0200 Subject: [PATCH 3/5] [go_router] added nextState, and currentState to OnEnter signature, and enhanced OnEnter test. --- .../example/lib/top_level_on_enter.dart | 408 +++++++++++++----- packages/go_router/lib/src/configuration.dart | 47 +- packages/go_router/lib/src/parser.dart | 65 ++- packages/go_router/lib/src/router.dart | 67 ++- packages/go_router/test/parser_test.dart | 149 ++++--- 5 files changed, 519 insertions(+), 217 deletions(-) diff --git a/packages/go_router/example/lib/top_level_on_enter.dart b/packages/go_router/example/lib/top_level_on_enter.dart index 011af13c0a5a..bb607122d765 100644 --- a/packages/go_router/example/lib/top_level_on_enter.dart +++ b/packages/go_router/example/lib/top_level_on_enter.dart @@ -5,108 +5,323 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +/// Simulated service for handling referrals and deep links +class ReferralService { + /// processReferralCode + static Future processReferralCode(String code) async { + // Simulate network delay + await Future.delayed(const Duration(seconds: 1)); + return true; + } + + /// trackDeepLink + static Future trackDeepLink(Uri uri) async { + // Simulate analytics tracking + await Future.delayed(const Duration(milliseconds: 300)); + debugPrint('Deep link tracked: $uri'); + } +} + void main() => runApp(const App()); /// The main application widget. class App extends StatelessWidget { - /// Constructs an [App]. + /// The main application widget. const App({super.key}); - /// The title of the app. - static const String title = 'GoRouter Example: Top-level onEnter'; - @override - Widget build(BuildContext context) => MaterialApp.router( - routerConfig: GoRouter( - initialLocation: '/home', - - /// A callback invoked for every route navigation attempt. - /// - /// If the callback returns `false`, the navigation is blocked. - /// Use this to handle authentication, referrals, or other route-based logic. - onEnter: (BuildContext context, GoRouterState state) { - // Save the referral code (if provided) and block navigation to the /referral route. - if (state.uri.path == '/referral') { - saveReferralCode(context, state.uri.queryParameters['code']); - return false; + Widget build(BuildContext context) { + final GlobalKey key = GlobalKey(); + + return MaterialApp.router( + routerConfig: _router(key), + title: 'Top-level onEnter', + theme: ThemeData( + useMaterial3: true, + primarySwatch: Colors.blue, + ), + ); + } + + /// Configures the router with navigation handling and deep link support. + GoRouter _router(GlobalKey key) { + return GoRouter( + navigatorKey: key, + initialLocation: '/home', + debugLogDiagnostics: true, + + /// Handles incoming routes before navigation occurs. + /// This callback can: + /// 1. Block navigation and perform actions (return false) + /// 2. Allow navigation to proceed (return true) + /// 3. Show loading states during async operations + onEnter: (BuildContext context, GoRouterState currentState, + GoRouterState nextState) { + // Track analytics for deep links + if (nextState.uri.hasQuery || nextState.uri.hasFragment) { + _handleDeepLinkTracking(nextState.uri); + } + + // Handle special routes + switch (nextState.uri.path) { + case '/referral': + _handleReferralDeepLink(context, nextState); + return false; // Prevent navigation + + case '/auth': + if (nextState.uri.queryParameters['token'] != null) { + _handleAuthCallback(context, nextState); + return false; // Prevent navigation } + return true; - return true; // Allow navigation for all other routes. - }, + default: + return true; // Allow navigation for all other routes + } + }, + routes: [ + GoRoute( + path: '/login', + builder: (BuildContext context, GoRouterState state) => + const LoginScreen(), + ), + GoRoute( + path: '/home', + builder: (BuildContext context, GoRouterState state) => + const HomeScreen(), + ), + GoRoute( + path: '/settings', + builder: (BuildContext context, GoRouterState state) => + const SettingsScreen(), + ), + // Add route for testing purposes, but it won't navigate + GoRoute( + path: '/referral', + builder: (BuildContext context, GoRouterState state) => + const SizedBox(), // Never reached + ), + ], + ); + } - /// The list of application routes. - routes: [ - GoRoute( - path: '/login', - builder: (BuildContext context, GoRouterState state) => - const LoginScreen(), - ), - GoRoute( - path: '/home', - builder: (BuildContext context, GoRouterState state) => - const HomeScreen(), + /// Handles tracking of deep links asynchronously + void _handleDeepLinkTracking(Uri uri) { + ReferralService.trackDeepLink(uri).catchError((dynamic error) { + debugPrint('Failed to track deep link: $error'); + }); + } + + /// Processes referral deep links with loading state + void _handleReferralDeepLink(BuildContext context, GoRouterState state) { + final String? code = state.uri.queryParameters['code']; + if (code == null) { + return; + } + + // Show loading immediately + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) => const Center( + child: Card( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Processing referral...'), + ], ), - GoRoute( - path: '/settings', - builder: (BuildContext context, GoRouterState state) => - const SettingsScreen(), + ), + ), + ), + ); + + // Process referral asynchronously + ReferralService.processReferralCode(code).then( + (bool success) { + if (!context.mounted) { + return; + } + + // Close loading dialog + Navigator.of(context).pop(); + + // Show result + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + success + ? 'Referral code $code applied successfully!' + : 'Failed to apply referral code', ), - ], + ), + ); + }, + onError: (dynamic error) { + if (!context.mounted) { + return; + } + + // Close loading dialog + Navigator.of(context).pop(); + + // Show error + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: $error'), + backgroundColor: Colors.red, + ), + ); + }, + ); + } + + /// Handles OAuth callback processing + void _handleAuthCallback(BuildContext context, GoRouterState state) { + final String token = state.uri.queryParameters['token']!; + + // Show processing state + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Processing authentication...'), + duration: Duration(seconds: 1), + ), + ); + + // Process auth token asynchronously + // Replace with your actual auth logic + Future(() async { + await Future.delayed(const Duration(seconds: 1)); + if (!context.mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Processed auth token: $token'), + ), + ); + }).catchError((dynamic error) { + if (!context.mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Auth error: $error'), + backgroundColor: Colors.red, ), - title: title, ); + }); + } } -/// The login screen widget. -class LoginScreen extends StatelessWidget { - /// Constructs a [LoginScreen]. - const LoginScreen({super.key}); +/// Demonstrates various navigation scenarios and deep link handling. +class HomeScreen extends StatelessWidget { + /// Demonstrates various navigation scenarios and deep link handling. + const HomeScreen({super.key}); @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text(App.title)), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ElevatedButton( - onPressed: () => context.go('/home'), - child: const Text('Go to Home'), - ), - ElevatedButton( - onPressed: () => context.go('/settings'), - child: const Text('Go to Settings'), - ), - ], + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Top-level onEnter'), + actions: [ + IconButton( + icon: const Icon(Icons.settings), + onPressed: () => context.go('/settings'), ), + ], + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Navigation examples + ElevatedButton.icon( + onPressed: () => context.go('/login'), + icon: const Icon(Icons.login), + label: const Text('Go to Login'), + ), + const SizedBox(height: 16), + + // Deep link examples + Text('Deep Link Tests', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + const _DeepLinkButton( + label: 'Process Referral', + path: '/referral?code=TEST123', + description: 'Processes code without navigation', + ), + const SizedBox(height: 8), + const _DeepLinkButton( + label: 'Auth Callback', + path: '/auth?token=abc123', + description: 'Simulates OAuth callback', + ), + ], ), - ); + ), + ); + } } -/// The home screen widget. -class HomeScreen extends StatelessWidget { - /// Constructs a [HomeScreen]. - const HomeScreen({super.key}); +/// A button that demonstrates a deep link scenario. +class _DeepLinkButton extends StatelessWidget { + const _DeepLinkButton({ + required this.label, + required this.path, + required this.description, + }); + + final String label; + final String path; + final String description; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + OutlinedButton( + onPressed: () => context.go(path), + child: Text(label), + ), + Text( + description, + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +/// Login screen implementation +class LoginScreen extends StatelessWidget { + /// Login screen implementation + + const LoginScreen({super.key}); @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text(App.title)), + appBar: AppBar(title: const Text('Login')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - ElevatedButton( - onPressed: () => context.go('/login'), - child: const Text('Go to Login'), - ), - ElevatedButton( - onPressed: () => context.go('/settings'), - child: const Text('Go to Settings'), - ), - ElevatedButton( - // This would typically be triggered by an incoming deep link. - onPressed: () => context.go('/referral?code=12345'), - child: const Text('Save Referral Code'), + ElevatedButton.icon( + onPressed: () => context.go('/home'), + icon: const Icon(Icons.home), + label: const Text('Go to Home'), ), ], ), @@ -114,41 +329,28 @@ class HomeScreen extends StatelessWidget { ); } -/// The settings screen widget. +/// Settings screen implementation class SettingsScreen extends StatelessWidget { - /// Constructs a [SettingsScreen]. + /// Settings screen implementation const SettingsScreen({super.key}); @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text(App.title)), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ElevatedButton( - onPressed: () => context.go('/login'), - child: const Text('Go to Login'), - ), - ElevatedButton( - onPressed: () => context.go('/home'), - child: const Text('Go to Home'), - ), - ], - ), + appBar: AppBar(title: const Text('Settings')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + ListTile( + title: const Text('Home'), + leading: const Icon(Icons.home), + onTap: () => context.go('/home'), + ), + ListTile( + title: const Text('Login'), + leading: const Icon(Icons.login), + onTap: () => context.go('/login'), + ), + ], ), ); } - -/// Saves a referral code. -/// -/// Displays a [SnackBar] with the referral code for demonstration purposes. -/// Replace this with real referral handling logic. -void saveReferralCode(BuildContext context, String? code) { - if (code != null) { - // Here you can implement logic to save the referral code as needed. - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Referral code saved: $code')), - ); - } -} diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index acf6f86271d1..3fea2937dbe6 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -18,10 +18,16 @@ import 'state.dart'; /// The signature of the redirect callback. typedef GoRouterRedirect = FutureOr Function( - BuildContext context, GoRouterState state); + BuildContext context, + GoRouterState state, +); /// The signature of the onEnter callback. -typedef OnEnter = bool Function(BuildContext context, GoRouterState state); +typedef OnEnter = bool Function( + BuildContext context, + GoRouterState currentState, + GoRouterState nextState, +); /// The route configuration for GoRouter configured by the app. class RouteConfiguration { @@ -30,7 +36,6 @@ class RouteConfiguration { this._routingConfig, { required this.navigatorKey, this.extraCodec, - this.onEnter, }) { _onRoutingTableChanged(); _routingConfig.addListener(_onRoutingTableChanged); @@ -56,7 +61,9 @@ class RouteConfiguration { // Check that each parentNavigatorKey refers to either a ShellRoute's // navigatorKey or the root navigator key. static bool _debugCheckParentNavigatorKeys( - List routes, List> allowedKeys) { + List routes, + List> allowedKeys, + ) { for (final RouteBase route in routes) { if (route is GoRoute) { final GlobalKey? parentKey = route.parentNavigatorKey; @@ -231,6 +238,9 @@ class RouteConfiguration { /// Top level page redirect. GoRouterRedirect get topRedirect => _routingConfig.value.redirect; + /// Top level page on enter. + OnEnter? get topOnEnter => _routingConfig.value.onEnter; + /// The limit for the number of consecutive redirects. int get redirectLimit => _routingConfig.value.redirectLimit; @@ -250,35 +260,6 @@ class RouteConfiguration { /// example. final Codec? extraCodec; - /// A callback invoked for every incoming route before it is processed. - /// - /// This callback allows you to control navigation by inspecting the incoming - /// route and conditionally preventing the navigation. If the callback returns - /// `true`, the GoRouter proceeds with the regular navigation and redirection - /// logic. If the callback returns `false`, the navigation is canceled. - /// - /// When a deep link opens the app and `onEnter` returns `false`, GoRouter - /// will automatically redirect to the initial route or '/'. - /// - /// Example: - /// ```dart - /// final GoRouter router = GoRouter( - /// routes: [...], - /// onEnter: (BuildContext context, Uri uri) { - /// if (uri.path == '/login' && isUserLoggedIn()) { - /// return false; // Prevent navigation to /login - /// } - /// if (uri.path == '/referral') { - /// // Save the referral code and prevent navigation - /// saveReferralCode(uri.queryParameters['code']); - /// return false; - /// } - /// return true; // Allow navigation - /// }, - /// ); - /// ``` - final OnEnter? onEnter; - final Map _nameToPath = {}; /// Looks up the url location by a [GoRoute]'s name. diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index af3c99329e90..3f70229106e7 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -46,9 +46,10 @@ class GoRouteInformationParser extends RouteInformationParser { final ParserExceptionHandler? onParserException; final RouteMatchListCodec _routeMatchListCodec; + final String? _initialLocation; - // Store the last successful match list so we can truly "stay" on the same route. + /// Store the last successful match list so we can truly "stay" on the same route. RouteMatchList? _lastMatchList; /// The future of current route parsing. @@ -59,21 +60,25 @@ class GoRouteInformationParser extends RouteInformationParser { final Random _random = Random(); - /// Called by the [Router]. The + /// Parses route information and handles navigation decisions based on various states and callbacks. + /// This is called by the [Router] when a new route needs to be processed, such as during deep linking, + /// browser navigation, or in-app navigation. @override Future parseRouteInformationWithDependencies( RouteInformation routeInformation, BuildContext context, ) { - // 1) Defensive check: if we get a null state, just return empty (unlikely). + // 1) Safety check: routeInformation.state should never be null in normal operation, + // but if it somehow is, return an empty route list rather than crashing. if (routeInformation.state == null) { return SynchronousFuture(RouteMatchList.empty); } final Object infoState = routeInformation.state!; - // 2) If state is not RouteInformationState => typically browser nav or state restoration - // => decode an existing match from the saved Map. + // 2) Handle restored or browser-initiated navigation + // Browser navigation (back/forward) and state restoration don't create a RouteInformationState, + // instead they provide a saved Map of the previous route state that needs to be decoded if (infoState is! RouteInformationState) { final RouteMatchList matchList = _routeMatchListCodec.decode(infoState as Map); @@ -83,35 +88,43 @@ class GoRouteInformationParser extends RouteInformationParser { if (value.isError && onParserException != null) { return onParserException!(context, value); } - _lastMatchList = value; // store after success + _lastMatchList = value; // Cache successful route for future reference return value; }); } - // 3) If there's an `onEnter` callback, let's see if we want to short-circuit. - // (Note that .host.isNotEmpty check is optional — depends on your scenario.) - - if (configuration.onEnter != null) { + // 3) Handle route interception via onEnter callback + if (configuration.topOnEnter != null) { + // Create route matches for the incoming navigation attempt final RouteMatchList onEnterMatches = configuration.findMatch( routeInformation.uri, extra: infoState.extra, ); - final GoRouterState state = + // Build states for the navigation decision + // nextState: Where we're trying to go + final GoRouterState nextState = configuration.buildTopLevelGoRouterState(onEnterMatches); - final bool canEnter = configuration.onEnter!( + // currentState: Where we are now (or nextState if this is initial launch) + final GoRouterState currentState = _lastMatchList != null + ? configuration.buildTopLevelGoRouterState(_lastMatchList!) + : nextState; + + // Let the app decide if this navigation should proceed + final bool canEnter = configuration.topOnEnter!( context, - state, + currentState, + nextState, ); + // If navigation was intercepted (canEnter == false): if (!canEnter) { - // The user "handled" the deep link => do NOT navigate. - // Return our *last known route* if possible. + // Stay on current route if we have one if (_lastMatchList != null) { return SynchronousFuture(_lastMatchList!); } else { - // Fallback if we've never parsed a route before: + // If no current route (e.g., app just launched), go to default location final Uri defaultUri = Uri.parse(_initialLocation ?? '/'); final RouteMatchList fallbackMatches = configuration.findMatch( defaultUri, @@ -123,7 +136,10 @@ class GoRouteInformationParser extends RouteInformationParser { } } - // 4) Otherwise, do normal route matching: + // 4) Normalize the URI path + // We want consistent route matching regardless of trailing slashes + // - Empty paths become "/" + // - Trailing slashes are removed (except for root "/") Uri uri = routeInformation.uri; if (uri.hasEmptyPath) { uri = uri.replace(path: '/'); @@ -131,6 +147,7 @@ class GoRouteInformationParser extends RouteInformationParser { uri = uri.replace(path: uri.path.substring(0, uri.path.length - 1)); } + // Find matching routes for the normalized URI final RouteMatchList initialMatches = configuration.findMatch( uri, extra: infoState.extra, @@ -139,15 +156,17 @@ class GoRouteInformationParser extends RouteInformationParser { log('No initial matches: ${routeInformation.uri.path}'); } - // 5) Possibly do a redirect: + // 5) Process any redirects defined in the route configuration + // Routes might need to redirect based on auth state or other conditions return debugParserFuture = _redirect(context, initialMatches).then((RouteMatchList matchList) { - // If error, call parser exception if any + // Handle any errors during route matching/redirection if (matchList.isError && onParserException != null) { return onParserException!(context, matchList); } - // 6) Check for redirect-only route leftover + // 6) Development-time check for redirect-only routes + // Redirect-only routes must actually redirect somewhere else assert(() { if (matchList.isNotEmpty) { assert( @@ -158,7 +177,8 @@ class GoRouteInformationParser extends RouteInformationParser { return true; }()); - // 7) If it's a push/replace etc., handle that + // 7) Handle specific navigation types (push, replace, etc.) + // Different navigation actions need different route stack manipulations final RouteMatchList updated = _updateRouteMatchList( matchList, baseRouteMatchList: infoState.baseRouteMatchList, @@ -166,7 +186,8 @@ class GoRouteInformationParser extends RouteInformationParser { type: infoState.type, ); - // 8) Save as our "last known good" config + // 8) Cache this successful route match for future reference + // We need this for comparison in onEnter and fallback in navigation failure _lastMatchList = updated; return updated; }); diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index 9cc856c20ace..ac82117be01f 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -41,7 +41,16 @@ class RoutingConfig { /// The [routes] must not be empty. const RoutingConfig({ required this.routes, + this.onEnter, + @Deprecated( + 'Use onEnter instead. ' + 'This feature will be removed in a future release.', + ) this.redirect = _defaultRedirect, + @Deprecated( + 'Use onEnter instead. ' + 'This feature will be removed in a future release.', + ) this.redirectLimit = 5, }); @@ -66,12 +75,49 @@ class RoutingConfig { /// changes. /// /// See [GoRouter]. + @Deprecated( + 'Use onEnter instead. ' + 'This feature will be removed in a future release.', + ) final GoRouterRedirect redirect; /// The maximum number of redirection allowed. /// /// See [GoRouter]. + @Deprecated( + 'Use onEnter instead. ' + 'This feature will be removed in a future release.', + ) final int redirectLimit; + + /// A callback invoked for every incoming route before it is processed. + /// + /// This callback allows you to control navigation by inspecting the incoming + /// route and conditionally preventing the navigation. If the callback returns + /// `true`, the GoRouter proceeds with the regular navigation and redirection + /// logic. If the callback returns `false`, the navigation is canceled. + /// + /// When a deep link opens the app and `onEnter` returns `false`, GoRouter + /// will automatically redirect to the initial route or '/'. + /// + /// Example: + /// ```dart + /// final GoRouter router = GoRouter( + /// routes: [...], + /// onEnter: (BuildContext context, Uri uri) { + /// if (uri.path == '/login' && isUserLoggedIn()) { + /// return false; // Prevent navigation to /login + /// } + /// if (uri.path == '/referral') { + /// // Save the referral code and prevent navigation + /// saveReferralCode(uri.queryParameters['code']); + /// return false; + /// } + /// return true; // Allow navigation + /// }, + /// ); + /// ``` + final OnEnter? onEnter; } /// The route configuration for the app. @@ -127,9 +173,17 @@ class GoRouter implements RouterConfig { GoExceptionHandler? onException, GoRouterPageBuilder? errorPageBuilder, GoRouterWidgetBuilder? errorBuilder, + @Deprecated( + 'Use onEnter instead. ' + 'This feature will be removed in a future release.', + ) GoRouterRedirect? redirect, - Listenable? refreshListenable, + @Deprecated( + 'Use onEnter instead. ' + 'This feature will be removed in a future release.', + ) int redirectLimit = 5, + Listenable? refreshListenable, bool routerNeglect = false, String? initialLocation, bool overridePlatformDefaultLocation = false, @@ -143,11 +197,12 @@ class GoRouter implements RouterConfig { return GoRouter.routingConfig( routingConfig: _ConstantRoutingConfig( RoutingConfig( - routes: routes, - redirect: redirect ?? RoutingConfig._defaultRedirect, - redirectLimit: redirectLimit), + routes: routes, + redirect: redirect ?? RoutingConfig._defaultRedirect, + onEnter: onEnter, + redirectLimit: redirectLimit, + ), ), - onEnter: onEnter, extraCodec: extraCodec, onException: onException, errorPageBuilder: errorPageBuilder, @@ -171,7 +226,6 @@ class GoRouter implements RouterConfig { GoRouter.routingConfig({ required ValueListenable routingConfig, Codec? extraCodec, - OnEnter? onEnter, GoExceptionHandler? onException, GoRouterPageBuilder? errorPageBuilder, GoRouterWidgetBuilder? errorBuilder, @@ -209,7 +263,6 @@ class GoRouter implements RouterConfig { _routingConfig, navigatorKey: navigatorKey, extraCodec: extraCodec, - onEnter: onEnter, ); final ParserExceptionHandler? parserExceptionHandler; diff --git a/packages/go_router/test/parser_test.dart b/packages/go_router/test/parser_test.dart index c7f1d1c443a8..4f949a9dd135 100644 --- a/packages/go_router/test/parser_test.dart +++ b/packages/go_router/test/parser_test.dart @@ -638,56 +638,101 @@ void main() { }); testWidgets( - 'GoRouteInformationParser short-circuits if onEnter returns false', - (WidgetTester tester) async { - bool onEnterCalled = false; - final GoRouter router = GoRouter( - // Provide a custom onEnter callback that always returns true. - onEnter: (BuildContext context, GoRouterState state) { - onEnterCalled = true; - return false; // Always prevent entering new uris. - }, - routes: [ - GoRoute( - path: '/', - builder: (_, __) => const Placeholder(), - routes: [ - GoRoute(path: 'abc', builder: (_, __) => const Placeholder()), - ], - ), - ], - ); - addTearDown(router.dispose); - - // Pump the widget so the router is actually in the tree. - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - - // Grab the parser we want to test. - final GoRouteInformationParser parser = router.routeInformationParser; - - final BuildContext context = tester.element(find.byType(Router)); - // Save what we consider "old route" (the route we're currently on). - final RouteMatchList oldConfiguration = - router.routerDelegate.currentConfiguration; - - // Attempt to parse a new deep link: "/abc" - final RouteInformation routeInfo = RouteInformation( - uri: Uri.parse('/abc'), - state: RouteInformationState(type: NavigatingType.go), - ); - final RouteMatchList newMatch = - await parser.parseRouteInformationWithDependencies( - routeInfo, - context, - ); - - // Because our onEnter returned `true`, we expect we "did nothing." - // => Check that the parser short-circuited (did not produce a new route). - expect(onEnterCalled, isTrue, reason: 'onEnter was not called.'); - expect( - newMatch, - equals(oldConfiguration), - reason: 'Expected the parser to short-circuit and keep the old route.', - ); - }); + 'GoRouteInformationParser handles onEnter navigation control correctly', + (WidgetTester tester) async { + // Track states for verification + GoRouterState? capturedCurrentState; + GoRouterState? capturedNextState; + int onEnterCallCount = 0; + + final GoRouter router = GoRouter( + initialLocation: '/', + onEnter: + (BuildContext context, GoRouterState current, GoRouterState next) { + onEnterCallCount++; + capturedCurrentState = current; + capturedNextState = next; + + // Block navigation only to /blocked route + return !next.uri.path.contains('blocked'); + }, + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const Placeholder(), + routes: [ + GoRoute( + path: 'allowed', + builder: (_, __) => const Placeholder(), + ), + GoRoute( + path: 'blocked', + builder: (_, __) => const Placeholder(), + ), + ], + ), + ], + ); + + // Important: Dispose router at end + addTearDown(() async { + router.dispose(); + // Allow pending timers and microtasks to complete + await tester.pumpAndSettle(); + }); + + // Initialize the router + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + final GoRouteInformationParser parser = router.routeInformationParser; + final BuildContext context = tester.element(find.byType(Router)); + + // Test Case 1: Initial Route + expect(onEnterCallCount, 1, + reason: 'onEnter should be called for initial route'); + expect( + capturedCurrentState?.uri.path, + capturedNextState?.uri.path, + reason: 'Initial route should have same current and next state', + ); + + // Test Case 2: Blocked Navigation + final RouteMatchList beforeBlockedNav = + router.routerDelegate.currentConfiguration; + + final RouteInformation blockedRouteInfo = RouteInformation( + uri: Uri.parse('/blocked'), + state: RouteInformationState(type: NavigatingType.go), + ); + + final RouteMatchList blockedMatch = + await parser.parseRouteInformationWithDependencies( + blockedRouteInfo, + context, + ); + + // Wait for any animations to complete + await tester.pumpAndSettle(); + + expect(onEnterCallCount, 2, + reason: 'onEnter should be called for blocked route'); + expect( + blockedMatch.uri.toString(), + equals(beforeBlockedNav.uri.toString()), + reason: 'Navigation to blocked route should retain previous uri', + ); + expect( + capturedCurrentState?.uri.path, + '/', + reason: 'Current state should be root path', + ); + expect( + capturedNextState?.uri.path, + '/blocked', + reason: 'Next state should be blocked path', + ); + + // Cleanup properly + await tester.pumpAndSettle(); + }, + ); } From aec8e470e726da93006682272a75fd811d2a25e9 Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Tue, 4 Feb 2025 21:14:02 +0200 Subject: [PATCH 4/5] Add router instance to OnEnter callback Provides access to GoRouter within OnEnter callback to support navigation during early routing stages when InheritedGoRouter is not yet available in the widget tree. --- .../example/lib/top_level_on_enter.dart | 8 +++++-- packages/go_router/lib/src/configuration.dart | 1 + packages/go_router/lib/src/parser.dart | 24 ++++++++++++++++++- packages/go_router/lib/src/router.dart | 1 + packages/go_router/test/parser_test.dart | 8 +++++-- 5 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/go_router/example/lib/top_level_on_enter.dart b/packages/go_router/example/lib/top_level_on_enter.dart index bb607122d765..46ded4917f9e 100644 --- a/packages/go_router/example/lib/top_level_on_enter.dart +++ b/packages/go_router/example/lib/top_level_on_enter.dart @@ -55,8 +55,12 @@ class App extends StatelessWidget { /// 1. Block navigation and perform actions (return false) /// 2. Allow navigation to proceed (return true) /// 3. Show loading states during async operations - onEnter: (BuildContext context, GoRouterState currentState, - GoRouterState nextState) { + onEnter: ( + BuildContext context, + GoRouterState currentState, + GoRouterState nextState, + GoRouter goRouter, + ) { // Track analytics for deep links if (nextState.uri.hasQuery || nextState.uri.hasFragment) { _handleDeepLinkTracking(nextState.uri); diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index 46fe1e037fa9..351a33bab56b 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -27,6 +27,7 @@ typedef OnEnter = bool Function( BuildContext context, GoRouterState currentState, GoRouterState nextState, + GoRouter goRouter, ); /// The route configuration for GoRouter configured by the app. diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index 3f70229106e7..6a5d7b79bf04 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -32,8 +32,10 @@ class GoRouteInformationParser extends RouteInformationParser { GoRouteInformationParser({ required this.configuration, required String? initialLocation, + required GoRouter fallbackRouter, required this.onParserException, - }) : _routeMatchListCodec = RouteMatchListCodec(configuration), + }) : _fallbackRouter = fallbackRouter, + _routeMatchListCodec = RouteMatchListCodec(configuration), _initialLocation = initialLocation; /// The route configuration used for parsing [RouteInformation]s. @@ -52,6 +54,25 @@ class GoRouteInformationParser extends RouteInformationParser { /// Store the last successful match list so we can truly "stay" on the same route. RouteMatchList? _lastMatchList; + /// The fallback [GoRouter] instance used during route information parsing. + /// + /// During initial app launch or deep linking, route parsing may occur before the + /// [InheritedGoRouter] is built in the widget tree. This makes [GoRouter.of] or + /// [GoRouter.maybeOf] unavailable through [BuildContext]. + /// + /// When route parsing happens in these early stages, [_fallbackRouter] ensures that + /// navigation APIs remain accessible to features like [OnEnter], which may need to + /// perform navigation before the widget tree is fully built. + /// + /// This is used internally by [GoRouter] to pass its own instance as + /// the fallback. You typically don't need to provide this when constructing a + /// [GoRouteInformationParser] directly. + /// + /// See also: + /// * [parseRouteInformationWithDependencies], which uses this fallback router + /// when [BuildContext]-based router access is unavailable. + final GoRouter _fallbackRouter; + /// The future of current route parsing. /// /// This is used for testing asynchronous redirection. @@ -116,6 +137,7 @@ class GoRouteInformationParser extends RouteInformationParser { context, currentState, nextState, + GoRouter.maybeOf(context) ?? _fallbackRouter, ); // If navigation was intercepted (canEnter == false): diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index f4a32595b9e3..9d7fb37d0823 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -282,6 +282,7 @@ class GoRouter implements RouterConfig { onParserException: parserExceptionHandler, configuration: configuration, initialLocation: initialLocation, + fallbackRouter: this, ); routeInformationProvider = GoRouteInformationProvider( diff --git a/packages/go_router/test/parser_test.dart b/packages/go_router/test/parser_test.dart index 4f949a9dd135..1a33ad5f2756 100644 --- a/packages/go_router/test/parser_test.dart +++ b/packages/go_router/test/parser_test.dart @@ -647,8 +647,12 @@ void main() { final GoRouter router = GoRouter( initialLocation: '/', - onEnter: - (BuildContext context, GoRouterState current, GoRouterState next) { + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) { onEnterCallCount++; capturedCurrentState = current; capturedNextState = next; From 1bd3c187c28dcb6a7d6b2e23521cb550b9a491ae Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Sat, 15 Feb 2025 10:32:01 +0200 Subject: [PATCH 5/5] [go_router] Async onEnter, improved redirection, and loop prevention. - Change onEnter callback signature to FutureOr for async operations. - Update _processOnEnter for asynchronous navigation decisions. - Use last successful match as fallback on blocked navigation, preventing recursion. - Remove deprecated fallbackRouter parameter. - Adjust redirect limit and loop detection for stable fallback endpoints. - Update tests for sync, async, and loop scenarios. - Improve documentation of design decisions. --- packages/go_router/lib/src/configuration.dart | 2 +- packages/go_router/lib/src/parser.dart | 182 +++++++++----- packages/go_router/lib/src/router.dart | 14 +- packages/go_router/test/parser_test.dart | 226 ++++++++++++++++++ 4 files changed, 347 insertions(+), 77 deletions(-) diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index 351a33bab56b..550c6da2f84d 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -23,7 +23,7 @@ typedef GoRouterRedirect = FutureOr Function( ); /// The signature of the onEnter callback. -typedef OnEnter = bool Function( +typedef OnEnter = FutureOr Function( BuildContext context, GoRouterState currentState, GoRouterState nextState, diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index 6a5d7b79bf04..35fab99ce4bb 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -32,9 +32,9 @@ class GoRouteInformationParser extends RouteInformationParser { GoRouteInformationParser({ required this.configuration, required String? initialLocation, - required GoRouter fallbackRouter, + required GoRouter router, required this.onParserException, - }) : _fallbackRouter = fallbackRouter, + }) : _router = router, _routeMatchListCodec = RouteMatchListCodec(configuration), _initialLocation = initialLocation; @@ -60,7 +60,7 @@ class GoRouteInformationParser extends RouteInformationParser { /// [InheritedGoRouter] is built in the widget tree. This makes [GoRouter.of] or /// [GoRouter.maybeOf] unavailable through [BuildContext]. /// - /// When route parsing happens in these early stages, [_fallbackRouter] ensures that + /// When route parsing happens in these early stages, [_router] ensures that /// navigation APIs remain accessible to features like [OnEnter], which may need to /// perform navigation before the widget tree is fully built. /// @@ -71,7 +71,7 @@ class GoRouteInformationParser extends RouteInformationParser { /// See also: /// * [parseRouteInformationWithDependencies], which uses this fallback router /// when [BuildContext]-based router access is unavailable. - final GoRouter _fallbackRouter; + final GoRouter _router; /// The future of current route parsing. /// @@ -81,6 +81,100 @@ class GoRouteInformationParser extends RouteInformationParser { final Random _random = Random(); + // Processes an onEnter navigation attempt. Returns an updated RouteMatchList. + // This is where the onEnter navigation logic happens. + // 1. Setting the Stage: + // We figure out the current and next states using the matchList and any previous successful match. + // 2. Calling onEnter: + // We call topOnEnter. It decides if navigation can happen. If yes, we update the match and return it. + // 3. The Safety Net (Last Successful Match): + // If navigation is blocked and we have a previous successful match, we go back to that. + // This provides a safe fallback (e.g., /) to prevent loops. + // 4. Loop Check: + // If there's no previous match, we check for loops. If the current URI is in the + // history, we're in a loop. Throw a GoException. + // 5. Redirection Limit: + // We check we haven't redirected too many times. + // 6. The Fallback (Initial Location): + // If not looping, and not over the redirect limit, go back to the start (initial location, + // usually /). We don't recurse. This treats places like / as final destinations, + // not part of a loop. + // This method avoids infinite loops but ensures we end up somewhere valid. Handling fallbacks + // like / prevents false loop detections and unnecessary recursion. It's about smooth, + // reliable navigation. + Future _processOnEnter( + BuildContext context, + RouteMatchList matchList, + List onEnterHistory, + ) async { + // Build states for onEnter + final GoRouterState nextState = + configuration.buildTopLevelGoRouterState(matchList); + final GoRouterState currentState = _lastMatchList != null + ? configuration.buildTopLevelGoRouterState(_lastMatchList!) + : nextState; + + // Invoke the onEnter callback + final bool canEnter = await configuration.topOnEnter!( + context, + currentState, + nextState, + _router, + ); + + // If navigation is allowed, update and return immediately + if (canEnter) { + _lastMatchList = matchList; + return _updateRouteMatchList( + matchList, + baseRouteMatchList: matchList, + completer: null, + type: NavigatingType.go, + ); + } + + // If we have a last successful match, use it as fallback WITHOUT recursion + if (_lastMatchList != null) { + return _updateRouteMatchList( + _lastMatchList!, + baseRouteMatchList: matchList, + completer: null, + type: NavigatingType.go, + ); + } + + // Check for loops + if (onEnterHistory.length > 1 && + onEnterHistory.any((RouteMatchList m) => m.uri == matchList.uri)) { + throw GoException( + 'onEnter redirect loop detected: ${onEnterHistory.map((RouteMatchList m) => m.uri).join(' => ')} => ${matchList.uri}', + ); + } + + // Check redirect limit before continuing + if (onEnterHistory.length >= configuration.redirectLimit) { + throw GoException( + 'Too many onEnter redirects: ${onEnterHistory.map((RouteMatchList m) => m.uri).join(' => ')}', + ); + } + + // Add current match to history + onEnterHistory.add(matchList); + + // Try initial location as fallback WITHOUT recursion + final RouteMatchList fallbackMatches = configuration.findMatch( + Uri.parse(_initialLocation ?? '/'), + extra: matchList.extra, + ); + + return _updateRouteMatchList( + fallbackMatches, + baseRouteMatchList: matchList, + completer: null, + type: NavigatingType.go, + ); + } + /// Parses route information and handles navigation decisions based on various states and callbacks. /// This is called by the [Router] when a new route needs to be processed, such as during deep linking, /// browser navigation, or in-app navigation. @@ -89,17 +183,14 @@ class GoRouteInformationParser extends RouteInformationParser { RouteInformation routeInformation, BuildContext context, ) { - // 1) Safety check: routeInformation.state should never be null in normal operation, - // but if it somehow is, return an empty route list rather than crashing. + // 1) Safety check if (routeInformation.state == null) { return SynchronousFuture(RouteMatchList.empty); } final Object infoState = routeInformation.state!; - // 2) Handle restored or browser-initiated navigation - // Browser navigation (back/forward) and state restoration don't create a RouteInformationState, - // instead they provide a saved Map of the previous route state that needs to be decoded + // 2) Handle restored navigation if (infoState is! RouteInformationState) { final RouteMatchList matchList = _routeMatchListCodec.decode(infoState as Map); @@ -109,59 +200,12 @@ class GoRouteInformationParser extends RouteInformationParser { if (value.isError && onParserException != null) { return onParserException!(context, value); } - _lastMatchList = value; // Cache successful route for future reference + _lastMatchList = value; return value; }); } - // 3) Handle route interception via onEnter callback - if (configuration.topOnEnter != null) { - // Create route matches for the incoming navigation attempt - final RouteMatchList onEnterMatches = configuration.findMatch( - routeInformation.uri, - extra: infoState.extra, - ); - - // Build states for the navigation decision - // nextState: Where we're trying to go - final GoRouterState nextState = - configuration.buildTopLevelGoRouterState(onEnterMatches); - - // currentState: Where we are now (or nextState if this is initial launch) - final GoRouterState currentState = _lastMatchList != null - ? configuration.buildTopLevelGoRouterState(_lastMatchList!) - : nextState; - - // Let the app decide if this navigation should proceed - final bool canEnter = configuration.topOnEnter!( - context, - currentState, - nextState, - GoRouter.maybeOf(context) ?? _fallbackRouter, - ); - - // If navigation was intercepted (canEnter == false): - if (!canEnter) { - // Stay on current route if we have one - if (_lastMatchList != null) { - return SynchronousFuture(_lastMatchList!); - } else { - // If no current route (e.g., app just launched), go to default location - final Uri defaultUri = Uri.parse(_initialLocation ?? '/'); - final RouteMatchList fallbackMatches = configuration.findMatch( - defaultUri, - extra: infoState.extra, - ); - _lastMatchList = fallbackMatches; - return SynchronousFuture(fallbackMatches); - } - } - } - - // 4) Normalize the URI path - // We want consistent route matching regardless of trailing slashes - // - Empty paths become "/" - // - Trailing slashes are removed (except for root "/") + // 3) Normalize the URI first Uri uri = routeInformation.uri; if (uri.hasEmptyPath) { uri = uri.replace(path: '/'); @@ -169,16 +213,28 @@ class GoRouteInformationParser extends RouteInformationParser { uri = uri.replace(path: uri.path.substring(0, uri.path.length - 1)); } - // Find matching routes for the normalized URI + // Find initial matches for the normalized URI final RouteMatchList initialMatches = configuration.findMatch( uri, extra: infoState.extra, ); + + // 4) Handle route interception via onEnter callback + if (configuration.topOnEnter != null) { + // Call _processOnEnter and immediately return its result + return _processOnEnter( + context, + initialMatches, + [initialMatches], // Start history with initial match + ); + } + + // 5) If onEnter isn't used or throws, continue with redirect processing if (initialMatches.isError) { log('No initial matches: ${routeInformation.uri.path}'); } - // 5) Process any redirects defined in the route configuration + // 6) Process any redirects defined in the route configuration // Routes might need to redirect based on auth state or other conditions return debugParserFuture = _redirect(context, initialMatches).then((RouteMatchList matchList) { @@ -187,7 +243,7 @@ class GoRouteInformationParser extends RouteInformationParser { return onParserException!(context, matchList); } - // 6) Development-time check for redirect-only routes + // 7) Development-time check for redirect-only routes // Redirect-only routes must actually redirect somewhere else assert(() { if (matchList.isNotEmpty) { @@ -199,7 +255,7 @@ class GoRouteInformationParser extends RouteInformationParser { return true; }()); - // 7) Handle specific navigation types (push, replace, etc.) + // 8) Handle specific navigation types (push, replace, etc.) // Different navigation actions need different route stack manipulations final RouteMatchList updated = _updateRouteMatchList( matchList, @@ -208,7 +264,7 @@ class GoRouteInformationParser extends RouteInformationParser { type: infoState.type, ); - // 8) Cache this successful route match for future reference + // 9) Cache this successful route match for future reference // We need this for comparison in onEnter and fallback in navigation failure _lastMatchList = updated; return updated; diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index 9d7fb37d0823..9022b5cd7bf5 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -47,10 +47,6 @@ class RoutingConfig { 'This feature will be removed in a future release.', ) this.redirect = _defaultRedirect, - @Deprecated( - 'Use onEnter instead. ' - 'This feature will be removed in a future release.', - ) this.redirectLimit = 5, }); @@ -84,10 +80,6 @@ class RoutingConfig { /// The maximum number of redirection allowed. /// /// See [GoRouter]. - @Deprecated( - 'Use onEnter instead. ' - 'This feature will be removed in a future release.', - ) final int redirectLimit; /// A callback invoked for every incoming route before it is processed. @@ -178,10 +170,6 @@ class GoRouter implements RouterConfig { 'This feature will be removed in a future release.', ) GoRouterRedirect? redirect, - @Deprecated( - 'Use onEnter instead. ' - 'This feature will be removed in a future release.', - ) int redirectLimit = 5, Listenable? refreshListenable, bool routerNeglect = false, @@ -282,7 +270,7 @@ class GoRouter implements RouterConfig { onParserException: parserExceptionHandler, configuration: configuration, initialLocation: initialLocation, - fallbackRouter: this, + router: this, ); routeInformationProvider = GoRouteInformationProvider( diff --git a/packages/go_router/test/parser_test.dart b/packages/go_router/test/parser_test.dart index 1a33ad5f2756..1847297d6baa 100644 --- a/packages/go_router/test/parser_test.dart +++ b/packages/go_router/test/parser_test.dart @@ -739,4 +739,230 @@ void main() { await tester.pumpAndSettle(); }, ); + testWidgets( + 'Navigation is blocked correctly when onEnter returns false', + (WidgetTester tester) async { + final List navigationAttempts = []; + String currentPath = '/'; + late final GoRouter router; + + router = GoRouter( + initialLocation: '/', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) { + navigationAttempts.add(next.uri.path); + currentPath = current.uri.path; + return !next.uri.path.contains('blocked'); + }, + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const Placeholder(), + routes: [ + GoRoute( + path: 'blocked', + builder: (_, __) => const Placeholder(), + ), + GoRoute( + path: 'allowed', + builder: (_, __) => const Placeholder(), + ), + ], + ), + ], + ); + + // Important: Add tearDown before any test code + addTearDown(() async { + router.dispose(); + await tester.pumpAndSettle(); // Allow pending timers to complete + }); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + final BuildContext context = tester.element(find.byType(Router)); + final GoRouteInformationParser parser = router.routeInformationParser; + + // Try blocked route + final RouteInformation blockedInfo = RouteInformation( + uri: Uri.parse('/blocked'), + state: RouteInformationState(type: NavigatingType.go), + ); + + final RouteMatchList blockedResult = + await parser.parseRouteInformationWithDependencies( + blockedInfo, + context, + ); + + expect(blockedResult.uri.path, '/'); + expect(currentPath, '/'); + expect(navigationAttempts, contains('/blocked')); + + // Try allowed route + final RouteInformation allowedInfo = RouteInformation( + uri: Uri.parse('/allowed'), + state: RouteInformationState(type: NavigatingType.go), + ); + + final RouteMatchList allowedResult = + await parser.parseRouteInformationWithDependencies( + allowedInfo, + context, + ); + + expect(allowedResult.uri.path, '/allowed'); + expect(navigationAttempts, contains('/allowed')); + + // Important: Final cleanup + await tester.pumpAndSettle(); + }, + ); + testWidgets( + 'onEnter returns safe fallback for blocked route without triggering loop detection', + (WidgetTester tester) async { + final List navigationAttempts = []; + int onEnterCallCount = 0; + + final GoRouter router = GoRouter( + initialLocation: '/', + redirectLimit: 3, + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) { + onEnterCallCount++; + navigationAttempts.add(next.uri.path); + // Only allow navigation when already at the safe fallback ('/') + return next.uri.path == '/'; + }, + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const Placeholder(), + routes: [ + GoRoute( + path: 'loop', + builder: (_, __) => const Placeholder(), + ), + ], + ), + ], + ); + + addTearDown(() async { + router.dispose(); + await tester.pumpAndSettle(); + }); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + final BuildContext context = tester.element(find.byType(Router)); + final GoRouteInformationParser parser = router.routeInformationParser; + + // Try navigating to '/loop', which onEnter always blocks. + final RouteInformation loopInfo = RouteInformation( + uri: Uri.parse('/loop'), + state: RouteInformationState(type: NavigatingType.go), + ); + + final RouteMatchList result = + await parser.parseRouteInformationWithDependencies(loopInfo, context); + + expect(result.uri.path, equals('/')); + expect(onEnterCallCount, greaterThanOrEqualTo(1)); + expect(navigationAttempts, contains('/loop')); + }, + ); + testWidgets('onEnter handles asynchronous decisions correctly', + (WidgetTester tester) async { + // Wrap our async test in runAsync so that real async timers run properly. + await tester.runAsync(() async { + final List navigationAttempts = []; + int onEnterCallCount = 0; + + final GoRouter router = GoRouter( + initialLocation: '/', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + onEnterCallCount++; + navigationAttempts.add(next.uri.path); + + // Simulate a short asynchronous operation (e.g., data fetch) + await Future.delayed(const Duration(milliseconds: 100)); + + // Block navigation for paths containing 'delayed-blocked' + return !next.uri.path.contains('delayed-blocked'); + }, + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const Placeholder(), + routes: [ + GoRoute( + path: 'delayed-allowed', + builder: (_, __) => const Placeholder(), + ), + GoRoute( + path: 'delayed-blocked', + builder: (_, __) => const Placeholder(), + ), + ], + ), + ], + ); + + // Tear down the router after the test + addTearDown(() async { + router.dispose(); + await tester.pumpAndSettle(); + }); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + final BuildContext context = tester.element(find.byType(Router)); + final GoRouteInformationParser parser = router.routeInformationParser; + + // Test Case 1: Allowed Route (with async delay) + final RouteInformation allowedInfo = RouteInformation( + uri: Uri.parse('/delayed-allowed'), + state: RouteInformationState(type: NavigatingType.go), + ); + + final RouteMatchList allowedResult = await parser + .parseRouteInformationWithDependencies(allowedInfo, context); + // Pump to advance the timer past our 100ms delay. + await tester.pump(const Duration(milliseconds: 150)); + await tester.pumpAndSettle(); + + expect(allowedResult.uri.path, '/delayed-allowed'); + expect(onEnterCallCount, greaterThan(0)); + expect(navigationAttempts, contains('/delayed-allowed')); + + // Test Case 2: Blocked Route (with async delay) + final RouteInformation blockedInfo = RouteInformation( + uri: Uri.parse('/delayed-blocked'), + state: RouteInformationState(type: NavigatingType.go), + ); + + final RouteMatchList blockedResult = await parser + .parseRouteInformationWithDependencies(blockedInfo, context); + // Again, pump past the delay. + await tester.pump(const Duration(milliseconds: 150)); + await tester.pumpAndSettle(); + + // Since we already have a last successful match (from the allowed route), + // our fallback returns that match. So we expect '/delayed-allowed'. + expect(blockedResult.uri.path, '/delayed-allowed'); + expect(onEnterCallCount, greaterThan(1)); + expect(navigationAttempts, contains('/delayed-blocked')); + }); + }); }