From 6be9a5d320a07249fde49cd868b40ccb036e4021 Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Sun, 22 Dec 2024 16:22:07 +0200 Subject: [PATCH 01/27] [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 9327b97442b..77068dffb83 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 00000000000..011af13c0a5 --- /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 cc671066218..acf6f86271d 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 d1981898a8b..af3c99329e9 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 11f40f505ca..9cc856c20ac 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 9cb4aa2a071..c7f1d1c443a 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 02/27] 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 77068dffb83..8fc8c12bee6 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 1113c4cbea9..a7a9e866d12 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 03/27] [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 011af13c0a5..bb607122d76 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 acf6f86271d..3fea2937dbe 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 af3c99329e9..3f70229106e 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 9cc856c20ac..ac82117be01 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 c7f1d1c443a..4f949a9dd13 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 04/27] 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 bb607122d76..46ded4917f9 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 46fe1e037fa..351a33bab56 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 3f70229106e..6a5d7b79bf0 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 f4a32595b9e..9d7fb37d082 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 4f949a9dd13..1a33ad5f275 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 05/27] [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 351a33bab56..550c6da2f84 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 6a5d7b79bf0..35fab99ce4b 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 9d7fb37d082..9022b5cd7bf 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 1a33ad5f275..1847297d6ba 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')); + }); + }); } From f28337e7aba94a3c25a4abafeec1a36f25405096 Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Tue, 18 Feb 2025 15:43:34 +0200 Subject: [PATCH 06/27] improved redirection and async handling. --- .../example/lib/top_level_on_enter.dart | 2 +- packages/go_router/lib/src/configuration.dart | 3 +- packages/go_router/lib/src/parser.dart | 237 +++++++------ packages/go_router/test/on_enter_test.dart | 311 +++++++++++++++++ packages/go_router/test/parser_test.dart | 329 ------------------ 5 files changed, 438 insertions(+), 444 deletions(-) create mode 100644 packages/go_router/test/on_enter_test.dart 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 46ded4917f9..da117b65ec6 100644 --- a/packages/go_router/example/lib/top_level_on_enter.dart +++ b/packages/go_router/example/lib/top_level_on_enter.dart @@ -60,7 +60,7 @@ class App extends StatelessWidget { GoRouterState currentState, GoRouterState nextState, GoRouter goRouter, - ) { + ) async { // 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 550c6da2f84..0d5c29103f2 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 = FutureOr Function( +typedef OnEnter = Future Function( BuildContext context, GoRouterState currentState, GoRouterState nextState, @@ -227,6 +227,7 @@ class RouteConfiguration { extra: matchList.extra, pageKey: const ValueKey('topLevel'), topRoute: matchList.lastOrNull?.route, + error: matchList.error, ); } diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index 35fab99ce4b..6f0b7d08e3f 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -81,117 +81,64 @@ 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( + /// Tracks the URIs of onEnter redirections. + final List _onEnterRedirectionHistory = []; + + /// Checks if the top-level onEnter callback allows navigation. + /// Returns true if allowed; otherwise, false. + /// If onEnter is null, navigation is always allowed. + Future _handleTopOnEnter( BuildContext context, - RouteMatchList matchList, - List onEnterHistory, - ) async { - // Build states for onEnter + RouteInformation routeInformation, + RouteInformationState infoState, + ) { + final OnEnter? topOnEnter = configuration.topOnEnter; + if (topOnEnter == null) { + return SynchronousFuture(true); + } + + // Build route matches for the incoming URI. + final RouteMatchList incomingMatches = configuration.findMatch( + routeInformation.uri, + extra: infoState.extra, + ); + + // Construct navigation states. final GoRouterState nextState = - configuration.buildTopLevelGoRouterState(matchList); + configuration.buildTopLevelGoRouterState(incomingMatches); final GoRouterState currentState = _lastMatchList != null ? configuration.buildTopLevelGoRouterState(_lastMatchList!) : nextState; - // Invoke the onEnter callback - final bool canEnter = await configuration.topOnEnter!( + // Execute the onEnter callback. + final FutureOr result = 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, + GoRouter.maybeOf(context) ?? _router, ); - return _updateRouteMatchList( - fallbackMatches, - baseRouteMatchList: matchList, - completer: null, - type: NavigatingType.go, - ); + // Wrap immediate results in a SynchronousFuture. + return (result is bool) + ? SynchronousFuture(result) + : Future.value(result); } - /// 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. + /// Parses route information and determines the navigation outcome. + /// Handles both legacy (non-RouteInformationState) and current route states. @override Future parseRouteInformationWithDependencies( RouteInformation routeInformation, BuildContext context, ) { - // 1) Safety check + // Safety check if (routeInformation.state == null) { return SynchronousFuture(RouteMatchList.empty); } final Object infoState = routeInformation.state!; - - // 2) Handle restored navigation if (infoState is! RouteInformationState) { + // Decode the legacy state and apply redirects. final RouteMatchList matchList = _routeMatchListCodec.decode(infoState as Map); @@ -205,7 +152,87 @@ class GoRouteInformationParser extends RouteInformationParser { }); } - // 3) Normalize the URI first + // handle redirection limit + if (configuration.topOnEnter != null) { + // A redirection is being triggered via onEnter. + _onEnterRedirectionHistory.add(routeInformation.uri); + if (_onEnterRedirectionHistory.length > configuration.redirectLimit) { + final String formattedHistory = + _formatOnEnterRedirectionHistory(_onEnterRedirectionHistory); + + final RouteMatchList errorMatchList = _errorRouteMatchList( + routeInformation.uri, + GoException('Too many onEnter calls detected: $formattedHistory'), + ); + + _onEnterRedirectionHistory.clear(); + return SynchronousFuture( + onParserException != null + ? onParserException!(context, errorMatchList) + : errorMatchList, + ); + } + } + + // Use onEnter to decide if navigation should proceed. + final Future canEnterFuture = _handleTopOnEnter( + context, + routeInformation, + infoState, + ); + + return canEnterFuture.then( + (bool canEnter) { + _onEnterRedirectionHistory.clear(); + if (!canEnter) { + // If navigation is blocked, return the last successful match or a fallback. + if (_lastMatchList != null) { + return SynchronousFuture(_lastMatchList!); + } else { + final Uri defaultUri = Uri.parse(_initialLocation ?? '/'); + final RouteMatchList fallbackMatches = configuration.findMatch( + defaultUri, + extra: infoState.extra, + ); + _lastMatchList = fallbackMatches; + return SynchronousFuture(fallbackMatches); + } + } else { + // Navigation allowed: clear redirection history. + return _navigate(routeInformation, context, infoState); + } + }, + ); + } + + /// The match used when there is an error during parsing. + static RouteMatchList _errorRouteMatchList( + Uri uri, + GoException exception, { + Object? extra, + }) { + return RouteMatchList( + matches: const [], + extra: extra, + error: exception, + uri: uri, + pathParameters: const {}, + ); + } + + /// Formats the redirection history for error messages. + String _formatOnEnterRedirectionHistory(List history) { + return history.map((Uri uri) => uri.toString()).join(' => '); + } + + /// Normalizes the URI, finds matching routes, processes redirects, + /// and updates the route match list based on the navigation type. + Future _navigate( + RouteInformation routeInformation, + BuildContext context, + RouteInformationState infoState, + ) { + // Normalize the URI: ensure it has a valid path and remove trailing slashes. Uri uri = routeInformation.uri; if (uri.hasEmptyPath) { uri = uri.replace(path: '/'); @@ -213,50 +240,35 @@ class GoRouteInformationParser extends RouteInformationParser { uri = uri.replace(path: uri.path.substring(0, uri.path.length - 1)); } - // Find initial matches for the normalized URI + // Find initial route matches. 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}'); } - // 6) Process any redirects defined in the route configuration - // Routes might need to redirect based on auth state or other conditions + // Process any defined redirects. return debugParserFuture = _redirect(context, initialMatches).then((RouteMatchList matchList) { - // Handle any errors during route matching/redirection if (matchList.isError && onParserException != null) { return onParserException!(context, matchList); } - // 7) Development-time check for redirect-only routes - // Redirect-only routes must actually redirect somewhere else + // Ensure that redirect-only routes actually perform a redirection. assert(() { if (matchList.isNotEmpty) { assert( - !matchList.last.route.redirectOnly, - 'A redirect-only route must redirect to a different location.\n' - 'Offending route: ${matchList.last.route}'); + !matchList.last.route.redirectOnly, + 'Redirect-only route must redirect to a new location.\n' + 'Offending route: ${matchList.last.route}', + ); } return true; }()); - // 8) Handle specific navigation types (push, replace, etc.) - // Different navigation actions need different route stack manipulations + // Update the route match list according to the navigation type (push, replace, etc.). final RouteMatchList updated = _updateRouteMatchList( matchList, baseRouteMatchList: infoState.baseRouteMatchList, @@ -264,8 +276,7 @@ class GoRouteInformationParser extends RouteInformationParser { type: infoState.type, ); - // 9) Cache this successful route match for future reference - // We need this for comparison in onEnter and fallback in navigation failure + // Cache the successful match list. _lastMatchList = updated; return updated; }); diff --git a/packages/go_router/test/on_enter_test.dart b/packages/go_router/test/on_enter_test.dart new file mode 100644 index 00000000000..f70a8fe7bbd --- /dev/null +++ b/packages/go_router/test/on_enter_test.dart @@ -0,0 +1,311 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +void main() { + group('GoRouter onEnter navigation control tests', () { + late GoRouter router; + + tearDown(() async { + router.dispose(); + }); + + testWidgets( + 'Initial route calls onEnter and sets current/next state correctly', + (WidgetTester tester) async { + GoRouterState? capturedCurrentState; + GoRouterState? capturedNextState; + int onEnterCallCount = 0; + + router = GoRouter( + initialLocation: '/', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + onEnterCallCount++; + capturedCurrentState = current; + capturedNextState = next; + return true; + }, + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const Placeholder(), + routes: [ + GoRoute( + path: 'allowed', + builder: (_, __) => const Placeholder(), + ), + GoRoute( + path: 'blocked', + builder: (_, __) => const Placeholder(), + ), + ], + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + expect(onEnterCallCount, equals(1)); + expect( + capturedCurrentState?.uri.path, + capturedNextState?.uri.path, + ); + }, + ); + + testWidgets( + 'Navigation is blocked correctly when onEnter returns false', + (WidgetTester tester) async { + final List navigationAttempts = []; + String currentPath = '/'; + + router = GoRouter( + initialLocation: '/', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + 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(), + ), + ], + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + final BuildContext context = + tester.element(find.byType(Router)); + final GoRouteInformationParser parser = router.routeInformationParser; + final RouteMatchList beforeBlockedNav = + router.routerDelegate.currentConfiguration; + + // Try blocked route + final RouteMatchList blockedMatch = + await parser.parseRouteInformationWithDependencies( + RouteInformation( + uri: Uri.parse('/blocked'), + state: RouteInformationState(type: NavigatingType.go), + ), + context, + ); + await tester.pumpAndSettle(); + + expect(blockedMatch.uri.toString(), + equals(beforeBlockedNav.uri.toString())); + expect(currentPath, equals('/')); + expect(navigationAttempts, contains('/blocked')); + + // Try allowed route + final RouteMatchList allowedMatch = + await parser.parseRouteInformationWithDependencies( + RouteInformation( + uri: Uri.parse('/allowed'), + state: RouteInformationState(type: NavigatingType.go), + ), + context, + ); + expect(allowedMatch.uri.path, equals('/allowed')); + expect(navigationAttempts, contains('/allowed')); + await tester.pumpAndSettle(); + }, + ); + }); + + group('onEnter redirection tests', () { + late GoRouter router; + + tearDown(() async { + router.dispose(); + }); + + testWidgets('allows navigation when onEnter does not exceed limit', + (WidgetTester tester) async { + int onEnterCallCount = 0; + + router = GoRouter( + initialLocation: '/home', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + onEnterCallCount++; + return !next.uri.path.contains('block'); + }, + routes: [ + GoRoute( + path: '/home', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Home'))), + routes: [ + GoRoute( + path: 'allowed', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Allowed'))), + ), + GoRoute( + path: 'block', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Blocked'))), + ), + ], + ), + ], + redirectLimit: 3, + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + final BuildContext context = tester.element(find.byType(Scaffold)); + final RouteMatchList matchList = await router.routeInformationParser + .parseRouteInformationWithDependencies( + RouteInformation( + uri: Uri.parse('/home/allowed'), + state: RouteInformationState(type: NavigatingType.go), + ), + context, + ); + + expect(matchList.uri.path, equals('/home/allowed')); + expect(onEnterCallCount, greaterThan(0)); + }); + + testWidgets( + 'recursive onEnter limit triggers onException and resets navigation', + (WidgetTester tester) async { + final Completer completer = Completer(); + Object? capturedError; + + router = GoRouter( + initialLocation: '/start', + redirectLimit: 2, + onException: + (BuildContext context, GoRouterState state, GoRouter goRouter) { + capturedError = state.error; + goRouter.go('/fallback'); + completer.complete(); + }, + onEnter: (BuildContext context, GoRouterState current, + GoRouterState next, GoRouter goRouter) async { + if (next.uri.path == '/recursive') { + goRouter.push('/recursive'); + return false; + } + return true; + }, + routes: [ + GoRoute( + path: '/start', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Start'))), + ), + GoRoute( + path: '/recursive', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Recursive'))), + ), + GoRoute( + path: '/fallback', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Fallback'))), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + router.go('/recursive'); + await completer.future; + await tester.pumpAndSettle(); + + expect(capturedError, isNotNull); + expect(capturedError.toString(), + contains('Too many onEnter calls detected')); + expect(find.text('Fallback'), findsOneWidget); + }); + testWidgets( + 'recursive onEnter limit triggers onException and resets navigation', + (WidgetTester tester) async { + final Completer completer = Completer(); + Object? capturedError; + + router = GoRouter( + initialLocation: '/start', + redirectLimit: 2, + onException: + (BuildContext context, GoRouterState state, GoRouter goRouter) { + capturedError = state.error; + goRouter.go('/fallback'); + completer.complete(); + }, + onEnter: (BuildContext context, GoRouterState current, + GoRouterState next, GoRouter goRouter) async { + if (next.uri.path == '/recursive') { + goRouter.push('/recursive'); + return false; + } + return true; + }, + routes: [ + GoRoute( + path: '/start', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Start'))), + ), + GoRoute( + path: '/recursive', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Recursive'))), + ), + GoRoute( + path: '/fallback', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Fallback'))), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + router.go('/recursive'); + await completer.future; + await tester.pumpAndSettle(); + + expect(capturedError, isNotNull); + expect(capturedError.toString(), + contains('Too many onEnter calls detected')); + expect(find.text('Fallback'), findsOneWidget); + }); + }); +} diff --git a/packages/go_router/test/parser_test.dart b/packages/go_router/test/parser_test.dart index 1847297d6ba..9cb4aa2a071 100644 --- a/packages/go_router/test/parser_test.dart +++ b/packages/go_router/test/parser_test.dart @@ -636,333 +636,4 @@ void main() { expect(match.matches, hasLength(1)); expect(matchesObj.error, isNull); }); - - testWidgets( - '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, - GoRouter goRouter, - ) { - 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(); - }, - ); - 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')); - }); - }); } From 4092405d39c023dfd378d7b4fd1798238b5dba7a Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Tue, 18 Feb 2025 21:04:13 +0200 Subject: [PATCH 07/27] extracting the onEnter logic into its own helper class. --- packages/go_router/lib/src/on_enter.dart | 183 ++++++++++++ packages/go_router/lib/src/parser.dart | 197 +++---------- packages/go_router/test/go_route_test.dart | 305 ++++++++++++++++++++ packages/go_router/test/on_enter_test.dart | 311 --------------------- 4 files changed, 532 insertions(+), 464 deletions(-) create mode 100644 packages/go_router/lib/src/on_enter.dart delete mode 100644 packages/go_router/test/on_enter_test.dart diff --git a/packages/go_router/lib/src/on_enter.dart b/packages/go_router/lib/src/on_enter.dart new file mode 100644 index 00000000000..79c056b8408 --- /dev/null +++ b/packages/go_router/lib/src/on_enter.dart @@ -0,0 +1,183 @@ +// on_enter.dart +// ignore_for_file: use_build_context_synchronously +// 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 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../go_router.dart'; + +/// Handles the `onEnter` callback logic and redirection history for GoRouter. +/// +/// This class encapsulates the logic to execute the top-level `onEnter` +/// callback, track redirection history, enforce the redirection limit, +/// and generate an error match list when the limit is exceeded. +class OnEnterHandler { + /// Creates an [OnEnterHandler] instance. + /// + /// [configuration] is the route configuration. + /// [router] is used to access GoRouter methods. + /// [onParserException] is the exception handler for the parser. + OnEnterHandler({ + required RouteConfiguration configuration, + required GoRouter router, + required ParserExceptionHandler? onParserException, + }) : _onParserException = onParserException, + _configuration = configuration, + _router = router; + + /// The route configuration for the current router. + /// + /// This object contains all the route definitions, redirection logic, and other + /// navigation settings. It is used to determine which routes match the incoming + /// URI and to build the corresponding navigation state. + final RouteConfiguration _configuration; + + /// Optional exception handler for route parsing errors. + /// + /// When an error occurs during route parsing (e.g., when the onEnter redirection + /// limit is exceeded), this handler is invoked with the current [BuildContext] + /// and a [RouteMatchList] that contains the error details. It must conform to the + /// [ParserExceptionHandler] typedef and is responsible for returning a fallback + /// [RouteMatchList]. + final ParserExceptionHandler? _onParserException; + + /// The [GoRouter] instance used to perform navigation actions. + /// + /// This instance provides access to various navigation methods and serves as a + /// fallback when the [BuildContext] does not have an inherited GoRouter. It is + /// essential for executing onEnter callbacks and handling redirections. + final GoRouter _router; + + /// A history of URIs encountered during onEnter redirections. + /// + /// This list tracks each URI that triggers an onEnter redirection and is used to + /// enforce the redirection limit defined in [RouteConfiguration.redirectLimit]. It + /// helps prevent infinite redirection loops by generating an error if the limit is exceeded. + final List _redirectionHistory = []; + + /// Executes the top-level `onEnter` callback and decides whether navigation + /// should proceed. + /// + /// This method first checks for redirection errors via + /// [_redirectionErrorMatchList]. If no error is found, it builds the current + /// and next navigation states, executes the onEnter callback, and based on its + /// result returns either [onCanEnter] or [onCanNotEnter]. + /// + /// [context] is the BuildContext. + /// [routeInformation] is the current RouteInformation. + /// [infoState] is the state embedded in the RouteInformation. + /// [lastMatchList] is the last successful match list (if any). + /// [onCanEnter] is called when navigation is allowed. + /// [onCanNotEnter] is called when navigation is blocked. + /// + /// Returns a Future that resolves to a [RouteMatchList]. + Future handleTopOnEnter( + BuildContext context, + RouteInformation routeInformation, + RouteInformationState infoState, + RouteMatchList? lastMatchList, + Future Function() onCanEnter, + Future Function() onCanNotEnter, + ) { + final OnEnter? topOnEnter = _configuration.topOnEnter; + // If no onEnter is configured, simply allow navigation. + if (topOnEnter == null) { + return onCanEnter(); + } + + // Check if the redirection history already exceeds the configured limit. + final RouteMatchList? redirectionErrorMatchList = + _redirectionErrorMatchList(context, routeInformation.uri, infoState); + + if (redirectionErrorMatchList != null) { + // Return immediately if the redirection limit is exceeded. + return SynchronousFuture(redirectionErrorMatchList); + } + + // Build route matches for the incoming URI. + final RouteMatchList incomingMatches = _configuration.findMatch( + routeInformation.uri, + extra: infoState.extra, + ); + + // Build the next navigation state. + final GoRouterState nextState = + _configuration.buildTopLevelGoRouterState(incomingMatches); + // Use the last successful state if available. + final GoRouterState currentState = lastMatchList != null + ? _configuration.buildTopLevelGoRouterState(lastMatchList) + : nextState; + + // Execute the onEnter callback and get a Future result. + final Future canEnterFuture = topOnEnter( + context, + currentState, + nextState, + _router, + ); + // Reset history after attempting the callback. + _resetRedirectionHistory(); + // Return the appropriate match list based on whether navigation is allowed. + return canEnterFuture.then( + (bool canEnter) => canEnter ? onCanEnter() : onCanNotEnter(), + ); + } + + /// Processes the redirection history and checks for redirection limits. + /// + /// Adds [redirectedUri] to the redirection history. If the number of redirections + /// exceeds [_configuration.redirectLimit], returns an error match list. + /// Otherwise, returns null. + RouteMatchList? _redirectionErrorMatchList( + BuildContext context, + Uri redirectedUri, + RouteInformationState infoState, + ) { + _redirectionHistory.add(redirectedUri); + if (_redirectionHistory.length > _configuration.redirectLimit) { + final String formattedHistory = + _formatOnEnterRedirectionHistory(_redirectionHistory); + final RouteMatchList errorMatchList = _errorRouteMatchList( + redirectedUri, + GoException('Too many onEnter calls detected: $formattedHistory'), + extra: infoState.extra, + ); + _resetRedirectionHistory(); + // Use onParserException if available to process the error match list. + return _onParserException != null + ? _onParserException(context, errorMatchList) + : errorMatchList; + } + return null; + } + + /// Resets the onEnter redirection history. + void _resetRedirectionHistory() { + _redirectionHistory.clear(); + } + + /// Formats the redirection history as a string for error messages. + String _formatOnEnterRedirectionHistory(List history) { + return history.map((Uri uri) => uri.toString()).join(' => '); + } + + /// Creates an error match list for a given [uri] and [exception]. + static RouteMatchList _errorRouteMatchList( + Uri uri, + GoException exception, { + Object? extra, + }) { + return RouteMatchList( + matches: const [], + extra: extra, + error: exception, + uri: uri, + pathParameters: const {}, + ); + } +} diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index 6f0b7d08e3f..20616df7743 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -1,5 +1,6 @@ +// go_route_information_parser.dart // ignore_for_file: use_build_context_synchronously -// Copyright 2013 The Flutter Authors. All rights reserved. +// 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. @@ -12,6 +13,7 @@ import 'package:flutter/widgets.dart'; import '../go_router.dart'; import 'logging.dart'; import 'match.dart'; +import 'on_enter.dart'; /// The function signature of [GoRouteInformationParser.onParserException]. /// @@ -26,7 +28,9 @@ typedef ParserExceptionHandler = RouteMatchList Function( ); /// Converts between incoming URLs and a [RouteMatchList] using [RouteMatcher]. -/// Also performs redirection using [RouteRedirector]. +/// +/// Also performs redirection using [RouteRedirector] and integrates the top-level +/// onEnter logic via [OnEnterHandler]. class GoRouteInformationParser extends RouteInformationParser { /// Creates a [GoRouteInformationParser]. GoRouteInformationParser({ @@ -34,114 +38,51 @@ class GoRouteInformationParser extends RouteInformationParser { required String? initialLocation, required GoRouter router, required this.onParserException, - }) : _router = router, - _routeMatchListCodec = RouteMatchListCodec(configuration), - _initialLocation = initialLocation; + }) : _routeMatchListCodec = RouteMatchListCodec(configuration), + _initialLocation = initialLocation, + _onEnterHandler = OnEnterHandler( + configuration: configuration, + router: router, + onParserException: onParserException, + ); /// The route configuration used for parsing [RouteInformation]s. final RouteConfiguration configuration; - /// The exception handler that is called when parser can't handle the incoming - /// uri. - /// - /// This method must return a [RouteMatchList] for the parsed result. + /// Exception handler for parser errors. final ParserExceptionHandler? onParserException; final RouteMatchListCodec _routeMatchListCodec; final String? _initialLocation; - /// Store the last successful match list so we can truly "stay" on the same route. + /// Stores the last successful match list to enable "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, [_router] 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 _router; + /// Instance of [OnEnterHandler] to process top-level onEnter logic. + final OnEnterHandler _onEnterHandler; - /// The future of current route parsing. - /// - /// This is used for testing asynchronous redirection. + /// The future of current route parsing (used for testing asynchronous redirection). @visibleForTesting Future? debugParserFuture; final Random _random = Random(); - /// Tracks the URIs of onEnter redirections. - final List _onEnterRedirectionHistory = []; - - /// Checks if the top-level onEnter callback allows navigation. - /// Returns true if allowed; otherwise, false. - /// If onEnter is null, navigation is always allowed. - Future _handleTopOnEnter( - BuildContext context, - RouteInformation routeInformation, - RouteInformationState infoState, - ) { - final OnEnter? topOnEnter = configuration.topOnEnter; - if (topOnEnter == null) { - return SynchronousFuture(true); - } - - // Build route matches for the incoming URI. - final RouteMatchList incomingMatches = configuration.findMatch( - routeInformation.uri, - extra: infoState.extra, - ); - - // Construct navigation states. - final GoRouterState nextState = - configuration.buildTopLevelGoRouterState(incomingMatches); - final GoRouterState currentState = _lastMatchList != null - ? configuration.buildTopLevelGoRouterState(_lastMatchList!) - : nextState; - - // Execute the onEnter callback. - final FutureOr result = topOnEnter( - context, - currentState, - nextState, - GoRouter.maybeOf(context) ?? _router, - ); - - // Wrap immediate results in a SynchronousFuture. - return (result is bool) - ? SynchronousFuture(result) - : Future.value(result); - } - - /// Parses route information and determines the navigation outcome. - /// Handles both legacy (non-RouteInformationState) and current route states. @override Future parseRouteInformationWithDependencies( RouteInformation routeInformation, BuildContext context, ) { - // Safety check + // Safety check: if no state is provided, return an empty match list. if (routeInformation.state == null) { return SynchronousFuture(RouteMatchList.empty); } final Object infoState = routeInformation.state!; + // Process legacy state if necessary. if (infoState is! RouteInformationState) { - // Decode the legacy state and apply redirects. final RouteMatchList matchList = _routeMatchListCodec.decode(infoState as Map); - return debugParserFuture = _redirect(context, matchList).then((RouteMatchList value) { if (value.isError && onParserException != null) { @@ -152,81 +93,31 @@ class GoRouteInformationParser extends RouteInformationParser { }); } - // handle redirection limit - if (configuration.topOnEnter != null) { - // A redirection is being triggered via onEnter. - _onEnterRedirectionHistory.add(routeInformation.uri); - if (_onEnterRedirectionHistory.length > configuration.redirectLimit) { - final String formattedHistory = - _formatOnEnterRedirectionHistory(_onEnterRedirectionHistory); - - final RouteMatchList errorMatchList = _errorRouteMatchList( - routeInformation.uri, - GoException('Too many onEnter calls detected: $formattedHistory'), - ); - - _onEnterRedirectionHistory.clear(); - return SynchronousFuture( - onParserException != null - ? onParserException!(context, errorMatchList) - : errorMatchList, - ); - } - } - - // Use onEnter to decide if navigation should proceed. - final Future canEnterFuture = _handleTopOnEnter( + return _onEnterHandler.handleTopOnEnter( context, routeInformation, infoState, - ); - - return canEnterFuture.then( - (bool canEnter) { - _onEnterRedirectionHistory.clear(); - if (!canEnter) { - // If navigation is blocked, return the last successful match or a fallback. - if (_lastMatchList != null) { - return SynchronousFuture(_lastMatchList!); - } else { - final Uri defaultUri = Uri.parse(_initialLocation ?? '/'); - final RouteMatchList fallbackMatches = configuration.findMatch( - defaultUri, - extra: infoState.extra, - ); - _lastMatchList = fallbackMatches; - return SynchronousFuture(fallbackMatches); - } + _lastMatchList, + () => _navigate(routeInformation, context, infoState), + () { + // If navigation is blocked, return the last successful match or a fallback. + if (_lastMatchList != null) { + return SynchronousFuture(_lastMatchList!); } else { - // Navigation allowed: clear redirection history. - return _navigate(routeInformation, context, infoState); + final Uri defaultUri = Uri.parse(_initialLocation ?? '/'); + final RouteMatchList fallbackMatches = configuration.findMatch( + defaultUri, + extra: infoState.extra, + ); + _lastMatchList = fallbackMatches; + return SynchronousFuture(fallbackMatches); } }, ); } - /// The match used when there is an error during parsing. - static RouteMatchList _errorRouteMatchList( - Uri uri, - GoException exception, { - Object? extra, - }) { - return RouteMatchList( - matches: const [], - extra: extra, - error: exception, - uri: uri, - pathParameters: const {}, - ); - } - - /// Formats the redirection history for error messages. - String _formatOnEnterRedirectionHistory(List history) { - return history.map((Uri uri) => uri.toString()).join(' => '); - } - - /// Normalizes the URI, finds matching routes, processes redirects, - /// and updates the route match list based on the navigation type. + /// Normalizes the URI, finds matching routes, processes redirects, and updates + /// the route match list based on the navigation type. Future _navigate( RouteInformation routeInformation, BuildContext context, @@ -256,7 +147,7 @@ class GoRouteInformationParser extends RouteInformationParser { return onParserException!(context, matchList); } - // Ensure that redirect-only routes actually perform a redirection. + // Validate that redirect-only routes actually perform a redirection. assert(() { if (matchList.isNotEmpty) { assert( @@ -268,7 +159,7 @@ class GoRouteInformationParser extends RouteInformationParser { return true; }()); - // Update the route match list according to the navigation type (push, replace, etc.). + // Update the route match list based on the navigation type. final RouteMatchList updated = _updateRouteMatchList( matchList, baseRouteMatchList: infoState.baseRouteMatchList, @@ -285,7 +176,7 @@ class GoRouteInformationParser extends RouteInformationParser { @override Future parseRouteInformation( RouteInformation routeInformation) { - // Not used in go_router, so we can unimplement or throw: + // Not used in go_router; instruct users to use parseRouteInformationWithDependencies. throw UnimplementedError( 'Use parseRouteInformationWithDependencies instead'); } @@ -300,6 +191,7 @@ class GoRouteInformationParser extends RouteInformationParser { (configuration.matches.last is ImperativeRouteMatch || configuration.matches.last is ShellRouteMatch)) { RouteMatchBase route = configuration.matches.last; + // Drill down to find the appropriate ImperativeRouteMatch. while (route is! ImperativeRouteMatch) { if (route is ShellRouteMatch && route.matches.isNotEmpty) { route = route.matches.last; @@ -317,7 +209,7 @@ class GoRouteInformationParser extends RouteInformationParser { ); } - // Just calls configuration.redirect, wrapped in synchronous future if needed. + /// Calls [configuration.redirect] and wraps the result in a synchronous future if needed. Future _redirect( BuildContext context, RouteMatchList matchList) { final FutureOr result = configuration.redirect( @@ -331,8 +223,7 @@ class GoRouteInformationParser extends RouteInformationParser { return result; } - // If the user performed push/pushReplacement, etc., we might wrap newMatches - // in ImperativeRouteMatches. + /// Updates the route match list based on the navigation type (push, replace, etc.). RouteMatchList _updateRouteMatchList( RouteMatchList newMatchList, { required RouteMatchList? baseRouteMatchList, @@ -377,8 +268,7 @@ class GoRouteInformationParser extends RouteInformationParser { case NavigatingType.go: return newMatchList; case NavigatingType.restore: - // If the URIs differ, we might want the new one; if they're the same, - // keep the old. + // If the URIs differ, use the new one; otherwise, keep the old. if (baseRouteMatchList!.uri.toString() != newMatchList.uri.toString()) { return newMatchList; } else { @@ -387,6 +277,7 @@ class GoRouteInformationParser extends RouteInformationParser { } } + /// Returns a unique [ValueKey] for a new route. ValueKey _getUniqueValueKey() { return ValueKey( String.fromCharCodes( diff --git a/packages/go_router/test/go_route_test.dart b/packages/go_router/test/go_route_test.dart index 752a1a16580..a9910f8c706 100644 --- a/packages/go_router/test/go_route_test.dart +++ b/packages/go_router/test/go_route_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; @@ -330,4 +332,307 @@ void main() { expect(hasError, isTrue); }); }); + group('GoRouter onEnter navigation control tests', () { + late GoRouter router; + + tearDown(() async { + router.dispose(); + }); + + testWidgets( + 'Initial route calls onEnter and sets current/next state correctly', + (WidgetTester tester) async { + GoRouterState? capturedCurrentState; + GoRouterState? capturedNextState; + int onEnterCallCount = 0; + + router = GoRouter( + initialLocation: '/', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + onEnterCallCount++; + capturedCurrentState = current; + capturedNextState = next; + return true; + }, + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const Placeholder(), + routes: [ + GoRoute( + path: 'allowed', + builder: (_, __) => const Placeholder(), + ), + GoRoute( + path: 'blocked', + builder: (_, __) => const Placeholder(), + ), + ], + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + expect(onEnterCallCount, equals(1)); + expect( + capturedCurrentState?.uri.path, + capturedNextState?.uri.path, + ); + }, + ); + + testWidgets( + 'Navigation is blocked correctly when onEnter returns false', + (WidgetTester tester) async { + final List navigationAttempts = []; + String currentPath = '/'; + + router = GoRouter( + initialLocation: '/', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + 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(), + ), + ], + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + final BuildContext context = + tester.element(find.byType(Router)); + final GoRouteInformationParser parser = router.routeInformationParser; + final RouteMatchList beforeBlockedNav = + router.routerDelegate.currentConfiguration; + + // Try blocked route + final RouteMatchList blockedMatch = + await parser.parseRouteInformationWithDependencies( + RouteInformation( + uri: Uri.parse('/blocked'), + state: RouteInformationState(type: NavigatingType.go), + ), + context, + ); + await tester.pumpAndSettle(); + + expect(blockedMatch.uri.toString(), + equals(beforeBlockedNav.uri.toString())); + expect(currentPath, equals('/')); + expect(navigationAttempts, contains('/blocked')); + + // Try allowed route + final RouteMatchList allowedMatch = + await parser.parseRouteInformationWithDependencies( + RouteInformation( + uri: Uri.parse('/allowed'), + state: RouteInformationState(type: NavigatingType.go), + ), + context, + ); + expect(allowedMatch.uri.path, equals('/allowed')); + expect(navigationAttempts, contains('/allowed')); + await tester.pumpAndSettle(); + }, + ); + }); + + group('onEnter redirection tests', () { + late GoRouter router; + + tearDown(() async { + router.dispose(); + }); + + testWidgets('allows navigation when onEnter does not exceed limit', + (WidgetTester tester) async { + int onEnterCallCount = 0; + + router = GoRouter( + initialLocation: '/home', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + onEnterCallCount++; + return !next.uri.path.contains('block'); + }, + routes: [ + GoRoute( + path: '/home', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Home'))), + routes: [ + GoRoute( + path: 'allowed', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Allowed'))), + ), + GoRoute( + path: 'block', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Blocked'))), + ), + ], + ), + ], + redirectLimit: 3, + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + final BuildContext context = tester.element(find.byType(Scaffold)); + final RouteMatchList matchList = await router.routeInformationParser + .parseRouteInformationWithDependencies( + RouteInformation( + uri: Uri.parse('/home/allowed'), + state: RouteInformationState(type: NavigatingType.go), + ), + context, + ); + + expect(matchList.uri.path, equals('/home/allowed')); + expect(onEnterCallCount, greaterThan(0)); + }); + + testWidgets( + 'recursive onEnter limit triggers onException and resets navigation', + (WidgetTester tester) async { + final Completer completer = Completer(); + Object? capturedError; + + router = GoRouter( + initialLocation: '/start', + redirectLimit: 2, + onException: + (BuildContext context, GoRouterState state, GoRouter goRouter) { + capturedError = state.error; + goRouter.go('/fallback'); + completer.complete(); + }, + onEnter: (BuildContext context, GoRouterState current, + GoRouterState next, GoRouter goRouter) async { + if (next.uri.path == '/recursive') { + goRouter.push('/recursive'); + return false; + } + return true; + }, + routes: [ + GoRoute( + path: '/start', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Start'))), + ), + GoRoute( + path: '/recursive', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Recursive'))), + ), + GoRoute( + path: '/fallback', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Fallback'))), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + router.go('/recursive'); + await completer.future; + await tester.pumpAndSettle(); + + expect(capturedError, isNotNull); + expect(capturedError.toString(), + contains('Too many onEnter calls detected')); + expect(find.text('Fallback'), findsOneWidget); + }); + testWidgets( + 'recursive onEnter limit triggers onException and resets navigation', + (WidgetTester tester) async { + final Completer completer = Completer(); + Object? capturedError; + + router = GoRouter( + initialLocation: '/start', + redirectLimit: 2, + onException: + (BuildContext context, GoRouterState state, GoRouter goRouter) { + capturedError = state.error; + goRouter.go('/fallback'); + completer.complete(); + }, + onEnter: (BuildContext context, GoRouterState current, + GoRouterState next, GoRouter goRouter) async { + if (next.uri.path == '/recursive') { + goRouter.push('/recursive'); + return false; + } + return true; + }, + routes: [ + GoRoute( + path: '/start', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Start'))), + ), + GoRoute( + path: '/recursive', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Recursive'))), + ), + GoRoute( + path: '/fallback', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Fallback'))), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + router.go('/recursive'); + await completer.future; + await tester.pumpAndSettle(); + + expect(capturedError, isNotNull); + expect(capturedError.toString(), + contains('Too many onEnter calls detected')); + expect(find.text('Fallback'), findsOneWidget); + }); + }); } diff --git a/packages/go_router/test/on_enter_test.dart b/packages/go_router/test/on_enter_test.dart deleted file mode 100644 index f70a8fe7bbd..00000000000 --- a/packages/go_router/test/on_enter_test.dart +++ /dev/null @@ -1,311 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:go_router/go_router.dart'; - -void main() { - group('GoRouter onEnter navigation control tests', () { - late GoRouter router; - - tearDown(() async { - router.dispose(); - }); - - testWidgets( - 'Initial route calls onEnter and sets current/next state correctly', - (WidgetTester tester) async { - GoRouterState? capturedCurrentState; - GoRouterState? capturedNextState; - int onEnterCallCount = 0; - - router = GoRouter( - initialLocation: '/', - onEnter: ( - BuildContext context, - GoRouterState current, - GoRouterState next, - GoRouter goRouter, - ) async { - onEnterCallCount++; - capturedCurrentState = current; - capturedNextState = next; - return true; - }, - routes: [ - GoRoute( - path: '/', - builder: (_, __) => const Placeholder(), - routes: [ - GoRoute( - path: 'allowed', - builder: (_, __) => const Placeholder(), - ), - GoRoute( - path: 'blocked', - builder: (_, __) => const Placeholder(), - ), - ], - ), - ], - ); - - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); - - expect(onEnterCallCount, equals(1)); - expect( - capturedCurrentState?.uri.path, - capturedNextState?.uri.path, - ); - }, - ); - - testWidgets( - 'Navigation is blocked correctly when onEnter returns false', - (WidgetTester tester) async { - final List navigationAttempts = []; - String currentPath = '/'; - - router = GoRouter( - initialLocation: '/', - onEnter: ( - BuildContext context, - GoRouterState current, - GoRouterState next, - GoRouter goRouter, - ) async { - 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(), - ), - ], - ), - ], - ); - - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); - - final BuildContext context = - tester.element(find.byType(Router)); - final GoRouteInformationParser parser = router.routeInformationParser; - final RouteMatchList beforeBlockedNav = - router.routerDelegate.currentConfiguration; - - // Try blocked route - final RouteMatchList blockedMatch = - await parser.parseRouteInformationWithDependencies( - RouteInformation( - uri: Uri.parse('/blocked'), - state: RouteInformationState(type: NavigatingType.go), - ), - context, - ); - await tester.pumpAndSettle(); - - expect(blockedMatch.uri.toString(), - equals(beforeBlockedNav.uri.toString())); - expect(currentPath, equals('/')); - expect(navigationAttempts, contains('/blocked')); - - // Try allowed route - final RouteMatchList allowedMatch = - await parser.parseRouteInformationWithDependencies( - RouteInformation( - uri: Uri.parse('/allowed'), - state: RouteInformationState(type: NavigatingType.go), - ), - context, - ); - expect(allowedMatch.uri.path, equals('/allowed')); - expect(navigationAttempts, contains('/allowed')); - await tester.pumpAndSettle(); - }, - ); - }); - - group('onEnter redirection tests', () { - late GoRouter router; - - tearDown(() async { - router.dispose(); - }); - - testWidgets('allows navigation when onEnter does not exceed limit', - (WidgetTester tester) async { - int onEnterCallCount = 0; - - router = GoRouter( - initialLocation: '/home', - onEnter: ( - BuildContext context, - GoRouterState current, - GoRouterState next, - GoRouter goRouter, - ) async { - onEnterCallCount++; - return !next.uri.path.contains('block'); - }, - routes: [ - GoRoute( - path: '/home', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Home'))), - routes: [ - GoRoute( - path: 'allowed', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Allowed'))), - ), - GoRoute( - path: 'block', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Blocked'))), - ), - ], - ), - ], - redirectLimit: 3, - ); - - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); - - final BuildContext context = tester.element(find.byType(Scaffold)); - final RouteMatchList matchList = await router.routeInformationParser - .parseRouteInformationWithDependencies( - RouteInformation( - uri: Uri.parse('/home/allowed'), - state: RouteInformationState(type: NavigatingType.go), - ), - context, - ); - - expect(matchList.uri.path, equals('/home/allowed')); - expect(onEnterCallCount, greaterThan(0)); - }); - - testWidgets( - 'recursive onEnter limit triggers onException and resets navigation', - (WidgetTester tester) async { - final Completer completer = Completer(); - Object? capturedError; - - router = GoRouter( - initialLocation: '/start', - redirectLimit: 2, - onException: - (BuildContext context, GoRouterState state, GoRouter goRouter) { - capturedError = state.error; - goRouter.go('/fallback'); - completer.complete(); - }, - onEnter: (BuildContext context, GoRouterState current, - GoRouterState next, GoRouter goRouter) async { - if (next.uri.path == '/recursive') { - goRouter.push('/recursive'); - return false; - } - return true; - }, - routes: [ - GoRoute( - path: '/start', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Start'))), - ), - GoRoute( - path: '/recursive', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Recursive'))), - ), - GoRoute( - path: '/fallback', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Fallback'))), - ), - ], - ); - - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); - - router.go('/recursive'); - await completer.future; - await tester.pumpAndSettle(); - - expect(capturedError, isNotNull); - expect(capturedError.toString(), - contains('Too many onEnter calls detected')); - expect(find.text('Fallback'), findsOneWidget); - }); - testWidgets( - 'recursive onEnter limit triggers onException and resets navigation', - (WidgetTester tester) async { - final Completer completer = Completer(); - Object? capturedError; - - router = GoRouter( - initialLocation: '/start', - redirectLimit: 2, - onException: - (BuildContext context, GoRouterState state, GoRouter goRouter) { - capturedError = state.error; - goRouter.go('/fallback'); - completer.complete(); - }, - onEnter: (BuildContext context, GoRouterState current, - GoRouterState next, GoRouter goRouter) async { - if (next.uri.path == '/recursive') { - goRouter.push('/recursive'); - return false; - } - return true; - }, - routes: [ - GoRoute( - path: '/start', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Start'))), - ), - GoRoute( - path: '/recursive', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Recursive'))), - ), - GoRoute( - path: '/fallback', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Fallback'))), - ), - ], - ); - - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); - - router.go('/recursive'); - await completer.future; - await tester.pumpAndSettle(); - - expect(capturedError, isNotNull); - expect(capturedError.toString(), - contains('Too many onEnter calls detected')); - expect(find.text('Fallback'), findsOneWidget); - }); - }); -} From c1c09d0130505dd7b4a9a0c27852f7d397a62a1b Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Tue, 18 Feb 2025 21:10:35 +0200 Subject: [PATCH 08/27] added named params to handleTopOnEnter. --- packages/go_router/lib/src/on_enter.dart | 16 ++++++++-------- packages/go_router/lib/src/parser.dart | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/go_router/lib/src/on_enter.dart b/packages/go_router/lib/src/on_enter.dart index 79c056b8408..4832bac8573 100644 --- a/packages/go_router/lib/src/on_enter.dart +++ b/packages/go_router/lib/src/on_enter.dart @@ -76,14 +76,14 @@ class OnEnterHandler { /// [onCanNotEnter] is called when navigation is blocked. /// /// Returns a Future that resolves to a [RouteMatchList]. - Future handleTopOnEnter( - BuildContext context, - RouteInformation routeInformation, - RouteInformationState infoState, - RouteMatchList? lastMatchList, - Future Function() onCanEnter, - Future Function() onCanNotEnter, - ) { + Future handleTopOnEnter({ + required BuildContext context, + required RouteInformation routeInformation, + required RouteInformationState infoState, + required RouteMatchList? lastMatchList, + required Future Function() onCanEnter, + required Future Function() onCanNotEnter, + }) { final OnEnter? topOnEnter = _configuration.topOnEnter; // If no onEnter is configured, simply allow navigation. if (topOnEnter == null) { diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index 20616df7743..f8347829125 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -94,12 +94,12 @@ class GoRouteInformationParser extends RouteInformationParser { } return _onEnterHandler.handleTopOnEnter( - context, - routeInformation, - infoState, - _lastMatchList, - () => _navigate(routeInformation, context, infoState), - () { + context: context, + routeInformation: routeInformation, + infoState: infoState, + lastMatchList: _lastMatchList, + onCanEnter: () => _navigate(routeInformation, context, infoState), + onCanNotEnter: () { // If navigation is blocked, return the last successful match or a fallback. if (_lastMatchList != null) { return SynchronousFuture(_lastMatchList!); From d9e6ea616e8a4ad0a0ab5bb60bb5ae17f2121941 Mon Sep 17 00:00:00 2001 From: cedvdb Date: Thu, 20 Feb 2025 10:13:37 +0100 Subject: [PATCH 09/27] move tests --- packages/go_router/test/go_route_test.dart | 305 -------------------- packages/go_router/test/go_router_test.dart | 297 +++++++++++++++++++ 2 files changed, 297 insertions(+), 305 deletions(-) diff --git a/packages/go_router/test/go_route_test.dart b/packages/go_router/test/go_route_test.dart index a9910f8c706..752a1a16580 100644 --- a/packages/go_router/test/go_route_test.dart +++ b/packages/go_router/test/go_route_test.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; @@ -332,307 +330,4 @@ void main() { expect(hasError, isTrue); }); }); - group('GoRouter onEnter navigation control tests', () { - late GoRouter router; - - tearDown(() async { - router.dispose(); - }); - - testWidgets( - 'Initial route calls onEnter and sets current/next state correctly', - (WidgetTester tester) async { - GoRouterState? capturedCurrentState; - GoRouterState? capturedNextState; - int onEnterCallCount = 0; - - router = GoRouter( - initialLocation: '/', - onEnter: ( - BuildContext context, - GoRouterState current, - GoRouterState next, - GoRouter goRouter, - ) async { - onEnterCallCount++; - capturedCurrentState = current; - capturedNextState = next; - return true; - }, - routes: [ - GoRoute( - path: '/', - builder: (_, __) => const Placeholder(), - routes: [ - GoRoute( - path: 'allowed', - builder: (_, __) => const Placeholder(), - ), - GoRoute( - path: 'blocked', - builder: (_, __) => const Placeholder(), - ), - ], - ), - ], - ); - - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); - - expect(onEnterCallCount, equals(1)); - expect( - capturedCurrentState?.uri.path, - capturedNextState?.uri.path, - ); - }, - ); - - testWidgets( - 'Navigation is blocked correctly when onEnter returns false', - (WidgetTester tester) async { - final List navigationAttempts = []; - String currentPath = '/'; - - router = GoRouter( - initialLocation: '/', - onEnter: ( - BuildContext context, - GoRouterState current, - GoRouterState next, - GoRouter goRouter, - ) async { - 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(), - ), - ], - ), - ], - ); - - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); - - final BuildContext context = - tester.element(find.byType(Router)); - final GoRouteInformationParser parser = router.routeInformationParser; - final RouteMatchList beforeBlockedNav = - router.routerDelegate.currentConfiguration; - - // Try blocked route - final RouteMatchList blockedMatch = - await parser.parseRouteInformationWithDependencies( - RouteInformation( - uri: Uri.parse('/blocked'), - state: RouteInformationState(type: NavigatingType.go), - ), - context, - ); - await tester.pumpAndSettle(); - - expect(blockedMatch.uri.toString(), - equals(beforeBlockedNav.uri.toString())); - expect(currentPath, equals('/')); - expect(navigationAttempts, contains('/blocked')); - - // Try allowed route - final RouteMatchList allowedMatch = - await parser.parseRouteInformationWithDependencies( - RouteInformation( - uri: Uri.parse('/allowed'), - state: RouteInformationState(type: NavigatingType.go), - ), - context, - ); - expect(allowedMatch.uri.path, equals('/allowed')); - expect(navigationAttempts, contains('/allowed')); - await tester.pumpAndSettle(); - }, - ); - }); - - group('onEnter redirection tests', () { - late GoRouter router; - - tearDown(() async { - router.dispose(); - }); - - testWidgets('allows navigation when onEnter does not exceed limit', - (WidgetTester tester) async { - int onEnterCallCount = 0; - - router = GoRouter( - initialLocation: '/home', - onEnter: ( - BuildContext context, - GoRouterState current, - GoRouterState next, - GoRouter goRouter, - ) async { - onEnterCallCount++; - return !next.uri.path.contains('block'); - }, - routes: [ - GoRoute( - path: '/home', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Home'))), - routes: [ - GoRoute( - path: 'allowed', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Allowed'))), - ), - GoRoute( - path: 'block', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Blocked'))), - ), - ], - ), - ], - redirectLimit: 3, - ); - - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); - - final BuildContext context = tester.element(find.byType(Scaffold)); - final RouteMatchList matchList = await router.routeInformationParser - .parseRouteInformationWithDependencies( - RouteInformation( - uri: Uri.parse('/home/allowed'), - state: RouteInformationState(type: NavigatingType.go), - ), - context, - ); - - expect(matchList.uri.path, equals('/home/allowed')); - expect(onEnterCallCount, greaterThan(0)); - }); - - testWidgets( - 'recursive onEnter limit triggers onException and resets navigation', - (WidgetTester tester) async { - final Completer completer = Completer(); - Object? capturedError; - - router = GoRouter( - initialLocation: '/start', - redirectLimit: 2, - onException: - (BuildContext context, GoRouterState state, GoRouter goRouter) { - capturedError = state.error; - goRouter.go('/fallback'); - completer.complete(); - }, - onEnter: (BuildContext context, GoRouterState current, - GoRouterState next, GoRouter goRouter) async { - if (next.uri.path == '/recursive') { - goRouter.push('/recursive'); - return false; - } - return true; - }, - routes: [ - GoRoute( - path: '/start', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Start'))), - ), - GoRoute( - path: '/recursive', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Recursive'))), - ), - GoRoute( - path: '/fallback', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Fallback'))), - ), - ], - ); - - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); - - router.go('/recursive'); - await completer.future; - await tester.pumpAndSettle(); - - expect(capturedError, isNotNull); - expect(capturedError.toString(), - contains('Too many onEnter calls detected')); - expect(find.text('Fallback'), findsOneWidget); - }); - testWidgets( - 'recursive onEnter limit triggers onException and resets navigation', - (WidgetTester tester) async { - final Completer completer = Completer(); - Object? capturedError; - - router = GoRouter( - initialLocation: '/start', - redirectLimit: 2, - onException: - (BuildContext context, GoRouterState state, GoRouter goRouter) { - capturedError = state.error; - goRouter.go('/fallback'); - completer.complete(); - }, - onEnter: (BuildContext context, GoRouterState current, - GoRouterState next, GoRouter goRouter) async { - if (next.uri.path == '/recursive') { - goRouter.push('/recursive'); - return false; - } - return true; - }, - routes: [ - GoRoute( - path: '/start', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Start'))), - ), - GoRoute( - path: '/recursive', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Recursive'))), - ), - GoRoute( - path: '/fallback', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Fallback'))), - ), - ], - ); - - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); - - router.go('/recursive'); - await completer.future; - await tester.pumpAndSettle(); - - expect(capturedError, isNotNull); - expect(capturedError.toString(), - contains('Too many onEnter calls detected')); - expect(find.text('Fallback'), findsOneWidget); - }); - }); } diff --git a/packages/go_router/test/go_router_test.dart b/packages/go_router/test/go_router_test.dart index d98f10711c5..148e8accd4d 100644 --- a/packages/go_router/test/go_router_test.dart +++ b/packages/go_router/test/go_router_test.dart @@ -6152,6 +6152,303 @@ void main() { expect(matches.uri.toString(), '/child-route'); expect(find.text('/child-route'), findsOneWidget); }); + + group('onEnter', () { + late GoRouter router; + + tearDown(() async { + router.dispose(); + }); + + testWidgets( + 'Should set current/next state correctly', + (WidgetTester tester) async { + GoRouterState? capturedCurrentState; + GoRouterState? capturedNextState; + int onEnterCallCount = 0; + + router = GoRouter( + initialLocation: '/', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + onEnterCallCount++; + capturedCurrentState = current; + capturedNextState = next; + return true; + }, + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const Placeholder(), + routes: [ + GoRoute( + path: 'allowed', + builder: (_, __) => const Placeholder(), + ), + GoRoute( + path: 'blocked', + builder: (_, __) => const Placeholder(), + ), + ], + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + expect(onEnterCallCount, equals(1)); + expect( + capturedCurrentState?.uri.path, + capturedNextState?.uri.path, + ); + }, + ); + + testWidgets( + 'Should block navigation when onEnter returns false', + (WidgetTester tester) async { + final List navigationAttempts = []; + String currentPath = '/'; + + router = GoRouter( + initialLocation: '/', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + 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(), + ), + ], + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + final BuildContext context = + tester.element(find.byType(Router)); + final GoRouteInformationParser parser = router.routeInformationParser; + final RouteMatchList beforeBlockedNav = + router.routerDelegate.currentConfiguration; + + // Try blocked route + final RouteMatchList blockedMatch = + await parser.parseRouteInformationWithDependencies( + RouteInformation( + uri: Uri.parse('/blocked'), + state: RouteInformationState(type: NavigatingType.go), + ), + context, + ); + await tester.pumpAndSettle(); + + expect(blockedMatch.uri.toString(), + equals(beforeBlockedNav.uri.toString())); + expect(currentPath, equals('/')); + expect(navigationAttempts, contains('/blocked')); + + // Try allowed route + final RouteMatchList allowedMatch = + await parser.parseRouteInformationWithDependencies( + RouteInformation( + uri: Uri.parse('/allowed'), + state: RouteInformationState(type: NavigatingType.go), + ), + context, + ); + expect(allowedMatch.uri.path, equals('/allowed')); + expect(navigationAttempts, contains('/allowed')); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('Should allow navigation when onEnter returns true', + (WidgetTester tester) async { + int onEnterCallCount = 0; + + router = GoRouter( + initialLocation: '/home', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + onEnterCallCount++; + return !next.uri.path.contains('block'); + }, + routes: [ + GoRoute( + path: '/home', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Home'))), + routes: [ + GoRoute( + path: 'allowed', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Allowed'))), + ), + GoRoute( + path: 'block', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Blocked'))), + ), + ], + ), + ], + redirectLimit: 3, + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + final BuildContext context = tester.element(find.byType(Scaffold)); + final RouteMatchList matchList = await router.routeInformationParser + .parseRouteInformationWithDependencies( + RouteInformation( + uri: Uri.parse('/home/allowed'), + state: RouteInformationState(type: NavigatingType.go), + ), + context, + ); + + expect(matchList.uri.path, equals('/home/allowed')); + expect(onEnterCallCount, greaterThan(0)); + }); + + testWidgets( + 'Should trigger onException and resets navigation when the redirection limit is exceeded', + (WidgetTester tester) async { + final Completer completer = Completer(); + Object? capturedError; + + router = GoRouter( + initialLocation: '/start', + redirectLimit: 2, + onException: + (BuildContext context, GoRouterState state, GoRouter goRouter) { + capturedError = state.error; + goRouter.go('/fallback'); + completer.complete(); + }, + onEnter: (BuildContext context, GoRouterState current, + GoRouterState next, GoRouter goRouter) async { + if (next.uri.path == '/recursive') { + goRouter.push('/recursive'); + return false; + } + return true; + }, + routes: [ + GoRoute( + path: '/start', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Start'))), + ), + GoRoute( + path: '/recursive', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Recursive'))), + ), + GoRoute( + path: '/fallback', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Fallback'))), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + router.go('/recursive'); + await completer.future; + await tester.pumpAndSettle(); + + expect(capturedError, isNotNull); + expect(capturedError.toString(), + contains('Too many onEnter calls detected')); + expect(find.text('Fallback'), findsOneWidget); + }); + + testWidgets( + 'Should trigger onException and resets navigation when the redirection limit is exceeded', + (WidgetTester tester) async { + final Completer completer = Completer(); + Object? capturedError; + + router = GoRouter( + initialLocation: '/start', + redirectLimit: 2, + onException: + (BuildContext context, GoRouterState state, GoRouter goRouter) { + capturedError = state.error; + goRouter.go('/fallback'); + completer.complete(); + }, + onEnter: (BuildContext context, GoRouterState current, + GoRouterState next, GoRouter goRouter) async { + if (next.uri.path == '/recursive') { + goRouter.push('/recursive'); + return false; + } + return true; + }, + routes: [ + GoRoute( + path: '/start', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Start'))), + ), + GoRoute( + path: '/recursive', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Recursive'))), + ), + GoRoute( + path: '/fallback', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Fallback'))), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + router.go('/recursive'); + await completer.future; + await tester.pumpAndSettle(); + + expect(capturedError, isNotNull); + expect(capturedError.toString(), + contains('Too many onEnter calls detected')); + expect(find.text('Fallback'), findsOneWidget); + }); + }); } class TestInheritedNotifier extends InheritedNotifier> { From 67df52a4cb50994d9002f1160e3abbd6938454fc Mon Sep 17 00:00:00 2001 From: cedvdb Date: Sun, 6 Apr 2025 21:51:56 +0200 Subject: [PATCH 10/27] added tests --- packages/go_router/test/go_router_test.dart | 265 ++++++++++++++++++++ 1 file changed, 265 insertions(+) diff --git a/packages/go_router/test/go_router_test.dart b/packages/go_router/test/go_router_test.dart index 148e8accd4d..8fbbd7eba0c 100644 --- a/packages/go_router/test/go_router_test.dart +++ b/packages/go_router/test/go_router_test.dart @@ -6448,6 +6448,271 @@ void main() { contains('Too many onEnter calls detected')); expect(find.text('Fallback'), findsOneWidget); }); + testWidgets('Should handle `go` usage in onEnter', + (WidgetTester tester) async { + bool isAuthenticatedResult = false; + + Future isAuthenticated() => + Future.value(isAuthenticatedResult); + + final StreamController<({String current, String next})> paramsSink = + StreamController<({String current, String next})>(); + final Stream<({String current, String next})> paramsStream = + paramsSink.stream.asBroadcastStream(); + + router = GoRouter( + initialLocation: '/home', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + final bool isProtected = next.uri.toString().contains('protected'); + paramsSink.add( + (current: current.uri.toString(), next: next.uri.toString())); + + if (!isProtected) { + return true; + } + if (await isAuthenticated()) { + return true; + } + router.go('/sign-in'); + return false; + }, + routes: [ + GoRoute( + path: '/home', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Home'))), + ), + GoRoute( + path: '/protected', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Protected'))), + ), + GoRoute( + path: '/sign-in', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Sign-in'))), + ), + ], + ); + + expect(paramsStream, emits((current: '/home', next: '/home'))); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + expect( + paramsStream, + emitsInOrder(<({String current, String next})>[ + (current: '/home', next: '/protected'), + (current: '/home', next: '/sign-in') + ]), + ); + router.go('/protected'); + await tester.pumpAndSettle(); + expect(router.state.uri.toString(), equals('/sign-in')); + + isAuthenticatedResult = true; + expect( + paramsStream, + emits((current: '/sign-in', next: '/protected')), + ); + router.go('/protected'); + await tester.pumpAndSettle(); + + expect(router.state.uri.toString(), equals('/protected')); + }); + + testWidgets('Should handle `push` usage in onEnter', + (WidgetTester tester) async { + const bool isAuthenticatedResult = false; + + Future isAuthenticated() => + Future.value(isAuthenticatedResult); + + final StreamController<({String current, String next})> paramsSink = + StreamController<({String current, String next})>(); + final Stream<({String current, String next})> paramsStream = + paramsSink.stream.asBroadcastStream(); + + router = GoRouter( + initialLocation: '/home', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + final bool isProtected = next.uri.toString().contains('protected'); + paramsSink.add( + (current: current.uri.toString(), next: next.uri.toString())); + if (!isProtected) { + return true; + } + if (await isAuthenticated()) { + return true; + } + await router.push('/sign-in').then((bool? isLoggedIn) { + if (isLoggedIn ?? false) { + router.go(next.uri.toString()); + } + }); + + return false; + }, + routes: [ + GoRoute( + path: '/home', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Home'))), + ), + GoRoute( + path: '/protected', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Protected'))), + ), + GoRoute( + path: '/sign-in', + builder: (_, __) => Scaffold( + appBar: AppBar( + title: const Text('Sign in'), + ), + body: const Center(child: Text('Sign-in')), + ), + ), + ], + ); + + expect(paramsStream, emits((current: '/home', next: '/home'))); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + router.go('/protected'); + expect( + paramsStream, + emitsInOrder(<({String current, String next})>[ + (current: '/home', next: '/protected'), + (current: '/home', next: '/sign-in') + ]), + ); + await tester.pumpAndSettle(); + + expect(router.state.uri.toString(), equals('/sign-in')); + expect(find.byType(BackButton), findsOneWidget); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + + expect(router.state.uri.toString(), equals('/home')); + }); + + testWidgets('Should allow redirection with query parameters', + (WidgetTester tester) async { + bool isAuthenticatedResult = false; + + Future isAuthenticated() => + Future.value(isAuthenticatedResult); + + final StreamController<({String current, String next})> paramsSink = + StreamController<({String current, String next})>(); + final Stream<({String current, String next})> paramsStream = + paramsSink.stream.asBroadcastStream(); + + void goToRedirect(GoRouter router, GoRouterState state) { + final String redirect = state.uri.queryParameters['redirectTo'] ?? ''; + if (redirect.isNotEmpty) { + router.go(Uri.decodeComponent(redirect)); + } + } + + router = GoRouter( + initialLocation: '/home', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + paramsSink.add( + (current: current.uri.toString(), next: next.uri.toString())); + final bool isProtected = next.uri.toString().startsWith('/protected'); + + if (!isProtected) { + return true; + } + if (await isAuthenticated()) { + return true; + } + await router.pushNamed('sign-in', + queryParameters: { + 'redirectTo': next.uri.toString() + }); + return false; + }, + routes: [ + GoRoute( + path: '/home', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Home'))), + ), + GoRoute( + path: '/protected', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Protected'))), + ), + GoRoute( + path: '/sign-in', + name: 'sign-in', + builder: (_, GoRouterState state) => Scaffold( + appBar: AppBar( + title: const Text('Sign in'), + ), + body: Center( + child: ElevatedButton( + child: const Text('Sign in'), + onPressed: () => goToRedirect(router, state)), + ), + ), + ), + ], + ); + + expect(paramsStream, emits((current: '/home', next: '/home'))); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + expect( + paramsStream, + emitsInOrder(<({String current, String next})>[ + (current: '/home', next: '/protected'), + (current: '/home', next: '/sign-in') + ]), + ); + router.go('/protected'); + + await tester.pumpAndSettle(); + expect(router.state.uri.toString(), + equals('/sign-in?redirectTo=%2Fprotected')); + + isAuthenticatedResult = true; + // TODO omar: This test is failing because current is still home here + expect( + paramsStream, + emits(( + current: '/sign-in?redirectTo=%2Fprotected', + next: '/protected' + ))); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(router.state.uri.toString(), equals('/protected')); + }); }); } From cc57519ba16f02613c5f3c83d25463408269ec26 Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Tue, 8 Apr 2025 22:37:23 +0200 Subject: [PATCH 11/27] [go_router] Fix onEnter callback exception handling and enhance tests 1. Fix exception handling in onEnter callback to properly catch and report both synchronous and asynchronous exceptions through the router's exception handling system. 2. Create a dedicated test file for onEnter functionality with expanded test coverage for various navigation scenarios. 3. Fix failing test case for query parameter redirection. 4. Enhance the example app with better error handling and UI patterns. 5. Improve code organization by moving relevant definitions to appropriate files. --- .../example/lib/top_level_on_enter.dart | 254 +++-- packages/go_router/lib/src/configuration.dart | 9 +- packages/go_router/lib/src/on_enter.dart | 216 +++-- packages/go_router/lib/src/parser.dart | 1 - packages/go_router/lib/src/router.dart | 1 + packages/go_router/test/go_router_test.dart | 562 ----------- packages/go_router/test/on_enter_test.dart | 901 ++++++++++++++++++ 7 files changed, 1222 insertions(+), 722 deletions(-) create mode 100644 packages/go_router/test/on_enter_test.dart 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 da117b65ec6..c56a70c2fc6 100644 --- a/packages/go_router/example/lib/top_level_on_enter.dart +++ b/packages/go_router/example/lib/top_level_on_enter.dart @@ -50,11 +50,38 @@ class App extends StatelessWidget { initialLocation: '/home', debugLogDiagnostics: true, + /// Exception handler to gracefully handle errors in navigation + onException: + (BuildContext context, GoRouterState state, GoRouter router) { + // Show a user-friendly error message + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Navigation error: ${state.error}'), + backgroundColor: Colors.red, + duration: const Duration(seconds: 5), + action: SnackBarAction( + label: 'Go Home', + onPressed: () => router.go('/home'), + ), + ), + ); + } + // Log the error for debugging + debugPrint('Router exception: ${state.error}'); + + // Navigate to error screen if needed + if (state.uri.path == '/crash-test') { + router.go('/error'); + } + }, + /// 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 + /// 4. Demonstrate exception handling onEnter: ( BuildContext context, GoRouterState currentState, @@ -69,21 +96,50 @@ class App extends StatelessWidget { // Handle special routes switch (nextState.uri.path) { case '/referral': - _handleReferralDeepLink(context, nextState); + final String? code = nextState.uri.queryParameters['code']; + if (code != null) { + // Use SnackBar for feedback instead of dialog + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Processing referral code...'), + duration: Duration(seconds: 2), + ), + ); + } + + // Process code in background - don't block with complex UI + await _processReferralCodeInBackground(context, code); + } return false; // Prevent navigation case '/auth': if (nextState.uri.queryParameters['token'] != null) { - _handleAuthCallback(context, nextState); + _handleAuthToken( + context, nextState.uri.queryParameters['token']!); return false; // Prevent navigation } return true; + case '/crash-test': + // Deliberately throw an exception to demonstrate error handling + throw Exception('Simulated error in onEnter callback!'); + + case '/bad-route': + // Runtime type error to test different error types + // ignore: unnecessary_cast + nextState.uri as int; + return true; + default: return true; // Allow navigation for all other routes } }, routes: [ + GoRoute( + path: '/', + redirect: (BuildContext context, GoRouterState state) => '/home', + ), GoRoute( path: '/login', builder: (BuildContext context, GoRouterState state) => @@ -99,12 +155,27 @@ class App extends StatelessWidget { builder: (BuildContext context, GoRouterState state) => const SettingsScreen(), ), - // Add route for testing purposes, but it won't navigate + // Add routes for demonstration purposes GoRoute( path: '/referral', builder: (BuildContext context, GoRouterState state) => const SizedBox(), // Never reached ), + GoRoute( + path: '/crash-test', + builder: (BuildContext context, GoRouterState state) => + const SizedBox(), // Never reached + ), + GoRoute( + path: '/bad-route', + builder: (BuildContext context, GoRouterState state) => + const SizedBox(), // Never reached + ), + GoRoute( + path: '/error', + builder: (BuildContext context, GoRouterState state) => + const ErrorScreen(), + ), ], ); } @@ -116,88 +187,55 @@ class App extends StatelessWidget { }); } - /// 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...'), - ], - ), - ), - ), - ), - ); - - // Process referral asynchronously - ReferralService.processReferralCode(code).then( - (bool success) { - if (!context.mounted) { - return; - } + /// Processes referral code in the background without blocking navigation + Future _processReferralCodeInBackground( + BuildContext context, String code) async { + try { + final bool success = await ReferralService.processReferralCode(code); - // Close loading dialog - Navigator.of(context).pop(); + if (!context.mounted) { + return; + } - // Show result - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - success - ? 'Referral code $code applied successfully!' - : 'Failed to apply referral code', - ), + // Show result with a simple SnackBar + 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(); + ), + ); + } catch (error) { + if (!context.mounted) { + return; + } - // Show error - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Error: $error'), - backgroundColor: Colors.red, - ), - ); - }, - ); + 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']!; + /// Handles OAuth tokens with minimal UI interaction + void _handleAuthToken(BuildContext context, String token) { + if (!context.mounted) { + return; + } - // Show processing state + // Just show feedback, avoid complex UI ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Processing authentication...'), - duration: Duration(seconds: 1), + SnackBar( + content: Text('Processing auth token: $token'), + duration: const Duration(seconds: 2), ), ); - // Process auth token asynchronously - // Replace with your actual auth logic + // Process in background Future(() async { await Future.delayed(const Duration(seconds: 1)); if (!context.mounted) { @@ -206,18 +244,7 @@ class App extends StatelessWidget { 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, + content: Text('Auth token processed: $token'), ), ); }); @@ -268,6 +295,23 @@ class HomeScreen extends StatelessWidget { path: '/auth?token=abc123', description: 'Simulates OAuth callback', ), + + // Exception Testing Section + const SizedBox(height: 24), + Text('Exception Handling Tests', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + const _DeepLinkButton( + label: 'Trigger Exception', + path: '/crash-test', + description: 'Throws exception in onEnter callback', + ), + const SizedBox(height: 8), + const _DeepLinkButton( + label: 'Type Error Exception', + path: '/bad-route', + description: 'Triggers a runtime type error', + ), ], ), ), @@ -312,7 +356,6 @@ class _DeepLinkButton extends StatelessWidget { /// Login screen implementation class LoginScreen extends StatelessWidget { /// Login screen implementation - const LoginScreen({super.key}); @override @@ -358,3 +401,40 @@ class SettingsScreen extends StatelessWidget { ), ); } + +/// Error screen implementation +class ErrorScreen extends StatelessWidget { + /// Error screen implementation + const ErrorScreen({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('Error'), + backgroundColor: Colors.red, + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + color: Colors.red, + size: 60, + ), + const SizedBox(height: 16), + const Text( + 'An error occurred during navigation', + style: TextStyle(fontSize: 18), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () => context.go('/home'), + icon: const Icon(Icons.home), + label: const Text('Return to Home'), + ), + ], + ), + ), + ); +} diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index 0d5c29103f2..c0428f6dc64 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -11,6 +11,7 @@ import 'package:flutter/widgets.dart'; import 'logging.dart'; import 'match.dart'; import 'misc/errors.dart'; +import 'on_enter.dart'; import 'path_utils.dart'; import 'route.dart'; import 'router.dart'; @@ -22,14 +23,6 @@ typedef GoRouterRedirect = FutureOr Function( GoRouterState state, ); -/// The signature of the onEnter callback. -typedef OnEnter = Future Function( - BuildContext context, - GoRouterState currentState, - GoRouterState nextState, - GoRouter goRouter, -); - /// The route configuration for GoRouter configured by the app. class RouteConfiguration { /// Constructs a [RouteConfiguration]. diff --git a/packages/go_router/lib/src/on_enter.dart b/packages/go_router/lib/src/on_enter.dart index 4832bac8573..0b8e205d9ed 100644 --- a/packages/go_router/lib/src/on_enter.dart +++ b/packages/go_router/lib/src/on_enter.dart @@ -1,6 +1,6 @@ -// on_enter.dart // ignore_for_file: use_build_context_synchronously -// Copyright 2013 The Flutter Authors. + +// 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. @@ -11,17 +11,31 @@ import 'package:flutter/widgets.dart'; import '../go_router.dart'; -/// Handles the `onEnter` callback logic and redirection history for GoRouter. +/// The signature for the top-level [onEnter] callback. +/// +/// This callback receives the [BuildContext], the current navigation state, +/// the state being navigated to, and a reference to the [GoRouter] instance. +/// It returns a [Future] which should resolve to `true` if navigation +/// is allowed, or `false` to block navigation. +typedef OnEnter = Future Function( + BuildContext context, + GoRouterState currentState, + GoRouterState nextState, + GoRouter goRouter, +); + +/// Handles the top-level [onEnter] callback logic and manages redirection history. /// -/// This class encapsulates the logic to execute the top-level `onEnter` -/// callback, track redirection history, enforce the redirection limit, -/// and generate an error match list when the limit is exceeded. +/// This class encapsulates the logic to execute the top-level [onEnter] callback, +/// enforce the redirection limit defined in the router configuration, and generate +/// an error match list when the limit is exceeded. It is used internally by [GoRouter] +/// during route parsing. class OnEnterHandler { /// Creates an [OnEnterHandler] instance. /// - /// [configuration] is the route configuration. - /// [router] is used to access GoRouter methods. - /// [onParserException] is the exception handler for the parser. + /// * [configuration] is the current route configuration containing all route definitions. + /// * [router] is the [GoRouter] instance used for navigation actions. + /// * [onParserException] is an optional exception handler invoked on route parsing errors. OnEnterHandler({ required RouteConfiguration configuration, required GoRouter router, @@ -30,67 +44,57 @@ class OnEnterHandler { _configuration = configuration, _router = router; - /// The route configuration for the current router. + /// The current route configuration. /// - /// This object contains all the route definitions, redirection logic, and other - /// navigation settings. It is used to determine which routes match the incoming - /// URI and to build the corresponding navigation state. + /// Contains all route definitions, redirection logic, and navigation settings. final RouteConfiguration _configuration; /// Optional exception handler for route parsing errors. /// - /// When an error occurs during route parsing (e.g., when the onEnter redirection - /// limit is exceeded), this handler is invoked with the current [BuildContext] - /// and a [RouteMatchList] that contains the error details. It must conform to the - /// [ParserExceptionHandler] typedef and is responsible for returning a fallback - /// [RouteMatchList]. + /// This handler is invoked when errors occur during route parsing (for example, + /// when the [onEnter] redirection limit is exceeded) to return a fallback [RouteMatchList]. final ParserExceptionHandler? _onParserException; /// The [GoRouter] instance used to perform navigation actions. /// - /// This instance provides access to various navigation methods and serves as a - /// fallback when the [BuildContext] does not have an inherited GoRouter. It is - /// essential for executing onEnter callbacks and handling redirections. + /// This provides access to the imperative navigation methods (like [go], [push], + /// [replace], etc.) and serves as a fallback reference in case the [BuildContext] + /// does not include a [GoRouter]. final GoRouter _router; - /// A history of URIs encountered during onEnter redirections. + /// A history of URIs encountered during [onEnter] redirections. /// - /// This list tracks each URI that triggers an onEnter redirection and is used to - /// enforce the redirection limit defined in [RouteConfiguration.redirectLimit]. It - /// helps prevent infinite redirection loops by generating an error if the limit is exceeded. + /// This list tracks every URI that triggers an [onEnter] redirection, ensuring that + /// the number of redirections does not exceed the limit defined in the router's configuration. final List _redirectionHistory = []; - /// Executes the top-level `onEnter` callback and decides whether navigation - /// should proceed. + /// Executes the top-level [onEnter] callback and determines whether navigation should proceed. + /// + /// It checks for redirection errors by verifying if the redirection history exceeds the + /// configured limit. If everything is within limits, this method builds the current and + /// next navigation states, then executes the [onEnter] callback. /// - /// This method first checks for redirection errors via - /// [_redirectionErrorMatchList]. If no error is found, it builds the current - /// and next navigation states, executes the onEnter callback, and based on its - /// result returns either [onCanEnter] or [onCanNotEnter]. + /// * If [onEnter] returns `true`, the [onCanEnter] callback is invoked to allow navigation. + /// * If [onEnter] returns `false`, the [onCanNotEnter] callback is invoked to block navigation. /// - /// [context] is the BuildContext. - /// [routeInformation] is the current RouteInformation. - /// [infoState] is the state embedded in the RouteInformation. - /// [lastMatchList] is the last successful match list (if any). - /// [onCanEnter] is called when navigation is allowed. - /// [onCanNotEnter] is called when navigation is blocked. + /// Exceptions thrown synchronously or asynchronously by [onEnter] are caught and processed + /// via the [_onParserException] handler if available. /// - /// Returns a Future that resolves to a [RouteMatchList]. + /// Returns a [Future] representing the final navigation state. Future handleTopOnEnter({ required BuildContext context, required RouteInformation routeInformation, required RouteInformationState infoState, - required RouteMatchList? lastMatchList, required Future Function() onCanEnter, required Future Function() onCanNotEnter, }) { final OnEnter? topOnEnter = _configuration.topOnEnter; - // If no onEnter is configured, simply allow navigation. + // If no onEnter is configured, allow navigation immediately. if (topOnEnter == null) { return onCanEnter(); } - // Check if the redirection history already exceeds the configured limit. + // Check if the redirection history exceeds the configured limit. final RouteMatchList? redirectionErrorMatchList = _redirectionErrorMatchList(context, routeInformation.uri, infoState); @@ -99,7 +103,7 @@ class OnEnterHandler { return SynchronousFuture(redirectionErrorMatchList); } - // Build route matches for the incoming URI. + // Find route matches for the incoming URI. final RouteMatchList incomingMatches = _configuration.findMatch( routeInformation.uri, extra: infoState.extra, @@ -107,32 +111,115 @@ class OnEnterHandler { // Build the next navigation state. final GoRouterState nextState = - _configuration.buildTopLevelGoRouterState(incomingMatches); - // Use the last successful state if available. - final GoRouterState currentState = lastMatchList != null - ? _configuration.buildTopLevelGoRouterState(lastMatchList) + _buildTopLevelGoRouterState(incomingMatches); + + // Get the current state from the router delegate. + final RouteMatchList currentMatchList = + _router.routerDelegate.currentConfiguration; + final GoRouterState currentState = currentMatchList.isNotEmpty + ? _buildTopLevelGoRouterState(currentMatchList) : nextState; - // Execute the onEnter callback and get a Future result. - final Future canEnterFuture = topOnEnter( - context, - currentState, - nextState, - _router, - ); - // Reset history after attempting the callback. + // Execute the onEnter callback in a try-catch to capture synchronous exceptions. + Future canEnterFuture; + try { + canEnterFuture = topOnEnter( + context, + currentState, + nextState, + _router, + ); + } catch (error) { + final RouteMatchList errorMatchList = _errorRouteMatchList( + routeInformation.uri, + error is GoException ? error : GoException(error.toString()), + extra: infoState.extra, + ); + + _resetRedirectionHistory(); + + return SynchronousFuture(_onParserException != null + ? _onParserException(context, errorMatchList) + : errorMatchList); + } + + // Reset the redirection history after attempting the callback. _resetRedirectionHistory(); - // Return the appropriate match list based on whether navigation is allowed. - return canEnterFuture.then( + + // Handle asynchronous completion and catch any errors. + return canEnterFuture.then( (bool canEnter) => canEnter ? onCanEnter() : onCanNotEnter(), + onError: (Object error, StackTrace stackTrace) { + final RouteMatchList errorMatchList = _errorRouteMatchList( + routeInformation.uri, + error is GoException ? error : GoException(error.toString()), + extra: infoState.extra, + ); + + return _onParserException != null + ? _onParserException(context, errorMatchList) + : errorMatchList; + }, + ); + } + + /// Builds a [GoRouterState] based on the given [matchList]. + /// + /// This method derives the effective URI, full path, path parameters, and extra data from + /// the topmost route match, drilling down through nested shells if necessary. + /// + /// Returns a constructed [GoRouterState] reflecting the current or next navigation state. + GoRouterState _buildTopLevelGoRouterState(RouteMatchList matchList) { + // Determine effective navigation state from the match list. + Uri effectiveUri = matchList.uri; + String? effectiveFullPath = matchList.fullPath; + Map effectivePathParams = matchList.pathParameters; + String effectiveMatchedLocation = matchList.uri.path; + Object? effectiveExtra = matchList.extra; // Base extra + + if (matchList.matches.isNotEmpty) { + RouteMatchBase lastMatch = matchList.matches.last; + // Drill down to the actual leaf match even inside shell routes. + while (lastMatch is ShellRouteMatch) { + if (lastMatch.matches.isEmpty) { + break; + } + lastMatch = lastMatch.matches.last; + } + + if (lastMatch is ImperativeRouteMatch) { + // Use state from the imperative match. + effectiveUri = lastMatch.matches.uri; + effectiveFullPath = lastMatch.matches.fullPath; + effectivePathParams = lastMatch.matches.pathParameters; + effectiveMatchedLocation = lastMatch.matches.uri.path; + effectiveExtra = lastMatch.matches.extra; + } else { + // For non-imperative matches, use the matched location and extra from the match list. + effectiveMatchedLocation = lastMatch.matchedLocation; + effectiveExtra = matchList.extra; + } + } + + return GoRouterState( + _configuration, + uri: effectiveUri, + matchedLocation: effectiveMatchedLocation, + name: matchList.lastOrNull?.route.name, + path: matchList.lastOrNull?.route.path, + fullPath: effectiveFullPath, + pathParameters: effectivePathParams, + extra: effectiveExtra, + pageKey: const ValueKey('topLevel'), + topRoute: matchList.lastOrNull?.route, + error: matchList.error, ); } - /// Processes the redirection history and checks for redirection limits. + /// Processes the redirection history and checks against the configured redirection limit. /// - /// Adds [redirectedUri] to the redirection history. If the number of redirections - /// exceeds [_configuration.redirectLimit], returns an error match list. - /// Otherwise, returns null. + /// Adds [redirectedUri] to the history and, if the limit is exceeded, returns an error + /// match list. Otherwise, returns null. RouteMatchList? _redirectionErrorMatchList( BuildContext context, Uri redirectedUri, @@ -148,7 +235,6 @@ class OnEnterHandler { extra: infoState.extra, ); _resetRedirectionHistory(); - // Use onParserException if available to process the error match list. return _onParserException != null ? _onParserException(context, errorMatchList) : errorMatchList; @@ -156,17 +242,19 @@ class OnEnterHandler { return null; } - /// Resets the onEnter redirection history. + /// Clears the redirection history. void _resetRedirectionHistory() { _redirectionHistory.clear(); } - /// Formats the redirection history as a string for error messages. + /// Formats the redirection history into a string for error reporting. String _formatOnEnterRedirectionHistory(List history) { return history.map((Uri uri) => uri.toString()).join(' => '); } - /// Creates an error match list for a given [uri] and [exception]. + /// Creates an error [RouteMatchList] for the given [uri] and [exception]. + /// + /// This is used to encapsulate errors encountered during redirection or parsing. static RouteMatchList _errorRouteMatchList( Uri uri, GoException exception, { diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index f8347829125..6124b46b034 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -97,7 +97,6 @@ class GoRouteInformationParser extends RouteInformationParser { context: context, routeInformation: routeInformation, infoState: infoState, - lastMatchList: _lastMatchList, onCanEnter: () => _navigate(routeInformation, context, infoState), onCanNotEnter: () { // If navigation is blocked, return the last successful match or a fallback. diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index 9022b5cd7bf..7ac50fc97c3 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -14,6 +14,7 @@ import 'information_provider.dart'; import 'logging.dart'; import 'match.dart'; import 'misc/inherited_router.dart'; +import 'on_enter.dart'; import 'parser.dart'; import 'route.dart'; import 'state.dart'; diff --git a/packages/go_router/test/go_router_test.dart b/packages/go_router/test/go_router_test.dart index 8fbbd7eba0c..d98f10711c5 100644 --- a/packages/go_router/test/go_router_test.dart +++ b/packages/go_router/test/go_router_test.dart @@ -6152,568 +6152,6 @@ void main() { expect(matches.uri.toString(), '/child-route'); expect(find.text('/child-route'), findsOneWidget); }); - - group('onEnter', () { - late GoRouter router; - - tearDown(() async { - router.dispose(); - }); - - testWidgets( - 'Should set current/next state correctly', - (WidgetTester tester) async { - GoRouterState? capturedCurrentState; - GoRouterState? capturedNextState; - int onEnterCallCount = 0; - - router = GoRouter( - initialLocation: '/', - onEnter: ( - BuildContext context, - GoRouterState current, - GoRouterState next, - GoRouter goRouter, - ) async { - onEnterCallCount++; - capturedCurrentState = current; - capturedNextState = next; - return true; - }, - routes: [ - GoRoute( - path: '/', - builder: (_, __) => const Placeholder(), - routes: [ - GoRoute( - path: 'allowed', - builder: (_, __) => const Placeholder(), - ), - GoRoute( - path: 'blocked', - builder: (_, __) => const Placeholder(), - ), - ], - ), - ], - ); - - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); - - expect(onEnterCallCount, equals(1)); - expect( - capturedCurrentState?.uri.path, - capturedNextState?.uri.path, - ); - }, - ); - - testWidgets( - 'Should block navigation when onEnter returns false', - (WidgetTester tester) async { - final List navigationAttempts = []; - String currentPath = '/'; - - router = GoRouter( - initialLocation: '/', - onEnter: ( - BuildContext context, - GoRouterState current, - GoRouterState next, - GoRouter goRouter, - ) async { - 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(), - ), - ], - ), - ], - ); - - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); - - final BuildContext context = - tester.element(find.byType(Router)); - final GoRouteInformationParser parser = router.routeInformationParser; - final RouteMatchList beforeBlockedNav = - router.routerDelegate.currentConfiguration; - - // Try blocked route - final RouteMatchList blockedMatch = - await parser.parseRouteInformationWithDependencies( - RouteInformation( - uri: Uri.parse('/blocked'), - state: RouteInformationState(type: NavigatingType.go), - ), - context, - ); - await tester.pumpAndSettle(); - - expect(blockedMatch.uri.toString(), - equals(beforeBlockedNav.uri.toString())); - expect(currentPath, equals('/')); - expect(navigationAttempts, contains('/blocked')); - - // Try allowed route - final RouteMatchList allowedMatch = - await parser.parseRouteInformationWithDependencies( - RouteInformation( - uri: Uri.parse('/allowed'), - state: RouteInformationState(type: NavigatingType.go), - ), - context, - ); - expect(allowedMatch.uri.path, equals('/allowed')); - expect(navigationAttempts, contains('/allowed')); - await tester.pumpAndSettle(); - }, - ); - - testWidgets('Should allow navigation when onEnter returns true', - (WidgetTester tester) async { - int onEnterCallCount = 0; - - router = GoRouter( - initialLocation: '/home', - onEnter: ( - BuildContext context, - GoRouterState current, - GoRouterState next, - GoRouter goRouter, - ) async { - onEnterCallCount++; - return !next.uri.path.contains('block'); - }, - routes: [ - GoRoute( - path: '/home', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Home'))), - routes: [ - GoRoute( - path: 'allowed', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Allowed'))), - ), - GoRoute( - path: 'block', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Blocked'))), - ), - ], - ), - ], - redirectLimit: 3, - ); - - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); - - final BuildContext context = tester.element(find.byType(Scaffold)); - final RouteMatchList matchList = await router.routeInformationParser - .parseRouteInformationWithDependencies( - RouteInformation( - uri: Uri.parse('/home/allowed'), - state: RouteInformationState(type: NavigatingType.go), - ), - context, - ); - - expect(matchList.uri.path, equals('/home/allowed')); - expect(onEnterCallCount, greaterThan(0)); - }); - - testWidgets( - 'Should trigger onException and resets navigation when the redirection limit is exceeded', - (WidgetTester tester) async { - final Completer completer = Completer(); - Object? capturedError; - - router = GoRouter( - initialLocation: '/start', - redirectLimit: 2, - onException: - (BuildContext context, GoRouterState state, GoRouter goRouter) { - capturedError = state.error; - goRouter.go('/fallback'); - completer.complete(); - }, - onEnter: (BuildContext context, GoRouterState current, - GoRouterState next, GoRouter goRouter) async { - if (next.uri.path == '/recursive') { - goRouter.push('/recursive'); - return false; - } - return true; - }, - routes: [ - GoRoute( - path: '/start', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Start'))), - ), - GoRoute( - path: '/recursive', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Recursive'))), - ), - GoRoute( - path: '/fallback', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Fallback'))), - ), - ], - ); - - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); - - router.go('/recursive'); - await completer.future; - await tester.pumpAndSettle(); - - expect(capturedError, isNotNull); - expect(capturedError.toString(), - contains('Too many onEnter calls detected')); - expect(find.text('Fallback'), findsOneWidget); - }); - - testWidgets( - 'Should trigger onException and resets navigation when the redirection limit is exceeded', - (WidgetTester tester) async { - final Completer completer = Completer(); - Object? capturedError; - - router = GoRouter( - initialLocation: '/start', - redirectLimit: 2, - onException: - (BuildContext context, GoRouterState state, GoRouter goRouter) { - capturedError = state.error; - goRouter.go('/fallback'); - completer.complete(); - }, - onEnter: (BuildContext context, GoRouterState current, - GoRouterState next, GoRouter goRouter) async { - if (next.uri.path == '/recursive') { - goRouter.push('/recursive'); - return false; - } - return true; - }, - routes: [ - GoRoute( - path: '/start', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Start'))), - ), - GoRoute( - path: '/recursive', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Recursive'))), - ), - GoRoute( - path: '/fallback', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Fallback'))), - ), - ], - ); - - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); - - router.go('/recursive'); - await completer.future; - await tester.pumpAndSettle(); - - expect(capturedError, isNotNull); - expect(capturedError.toString(), - contains('Too many onEnter calls detected')); - expect(find.text('Fallback'), findsOneWidget); - }); - testWidgets('Should handle `go` usage in onEnter', - (WidgetTester tester) async { - bool isAuthenticatedResult = false; - - Future isAuthenticated() => - Future.value(isAuthenticatedResult); - - final StreamController<({String current, String next})> paramsSink = - StreamController<({String current, String next})>(); - final Stream<({String current, String next})> paramsStream = - paramsSink.stream.asBroadcastStream(); - - router = GoRouter( - initialLocation: '/home', - onEnter: ( - BuildContext context, - GoRouterState current, - GoRouterState next, - GoRouter goRouter, - ) async { - final bool isProtected = next.uri.toString().contains('protected'); - paramsSink.add( - (current: current.uri.toString(), next: next.uri.toString())); - - if (!isProtected) { - return true; - } - if (await isAuthenticated()) { - return true; - } - router.go('/sign-in'); - return false; - }, - routes: [ - GoRoute( - path: '/home', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Home'))), - ), - GoRoute( - path: '/protected', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Protected'))), - ), - GoRoute( - path: '/sign-in', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Sign-in'))), - ), - ], - ); - - expect(paramsStream, emits((current: '/home', next: '/home'))); - - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); - - expect( - paramsStream, - emitsInOrder(<({String current, String next})>[ - (current: '/home', next: '/protected'), - (current: '/home', next: '/sign-in') - ]), - ); - router.go('/protected'); - await tester.pumpAndSettle(); - expect(router.state.uri.toString(), equals('/sign-in')); - - isAuthenticatedResult = true; - expect( - paramsStream, - emits((current: '/sign-in', next: '/protected')), - ); - router.go('/protected'); - await tester.pumpAndSettle(); - - expect(router.state.uri.toString(), equals('/protected')); - }); - - testWidgets('Should handle `push` usage in onEnter', - (WidgetTester tester) async { - const bool isAuthenticatedResult = false; - - Future isAuthenticated() => - Future.value(isAuthenticatedResult); - - final StreamController<({String current, String next})> paramsSink = - StreamController<({String current, String next})>(); - final Stream<({String current, String next})> paramsStream = - paramsSink.stream.asBroadcastStream(); - - router = GoRouter( - initialLocation: '/home', - onEnter: ( - BuildContext context, - GoRouterState current, - GoRouterState next, - GoRouter goRouter, - ) async { - final bool isProtected = next.uri.toString().contains('protected'); - paramsSink.add( - (current: current.uri.toString(), next: next.uri.toString())); - if (!isProtected) { - return true; - } - if (await isAuthenticated()) { - return true; - } - await router.push('/sign-in').then((bool? isLoggedIn) { - if (isLoggedIn ?? false) { - router.go(next.uri.toString()); - } - }); - - return false; - }, - routes: [ - GoRoute( - path: '/home', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Home'))), - ), - GoRoute( - path: '/protected', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Protected'))), - ), - GoRoute( - path: '/sign-in', - builder: (_, __) => Scaffold( - appBar: AppBar( - title: const Text('Sign in'), - ), - body: const Center(child: Text('Sign-in')), - ), - ), - ], - ); - - expect(paramsStream, emits((current: '/home', next: '/home'))); - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); - - router.go('/protected'); - expect( - paramsStream, - emitsInOrder(<({String current, String next})>[ - (current: '/home', next: '/protected'), - (current: '/home', next: '/sign-in') - ]), - ); - await tester.pumpAndSettle(); - - expect(router.state.uri.toString(), equals('/sign-in')); - expect(find.byType(BackButton), findsOneWidget); - - await tester.tap(find.byType(BackButton)); - await tester.pumpAndSettle(); - - expect(router.state.uri.toString(), equals('/home')); - }); - - testWidgets('Should allow redirection with query parameters', - (WidgetTester tester) async { - bool isAuthenticatedResult = false; - - Future isAuthenticated() => - Future.value(isAuthenticatedResult); - - final StreamController<({String current, String next})> paramsSink = - StreamController<({String current, String next})>(); - final Stream<({String current, String next})> paramsStream = - paramsSink.stream.asBroadcastStream(); - - void goToRedirect(GoRouter router, GoRouterState state) { - final String redirect = state.uri.queryParameters['redirectTo'] ?? ''; - if (redirect.isNotEmpty) { - router.go(Uri.decodeComponent(redirect)); - } - } - - router = GoRouter( - initialLocation: '/home', - onEnter: ( - BuildContext context, - GoRouterState current, - GoRouterState next, - GoRouter goRouter, - ) async { - paramsSink.add( - (current: current.uri.toString(), next: next.uri.toString())); - final bool isProtected = next.uri.toString().startsWith('/protected'); - - if (!isProtected) { - return true; - } - if (await isAuthenticated()) { - return true; - } - await router.pushNamed('sign-in', - queryParameters: { - 'redirectTo': next.uri.toString() - }); - return false; - }, - routes: [ - GoRoute( - path: '/home', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Home'))), - ), - GoRoute( - path: '/protected', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Protected'))), - ), - GoRoute( - path: '/sign-in', - name: 'sign-in', - builder: (_, GoRouterState state) => Scaffold( - appBar: AppBar( - title: const Text('Sign in'), - ), - body: Center( - child: ElevatedButton( - child: const Text('Sign in'), - onPressed: () => goToRedirect(router, state)), - ), - ), - ), - ], - ); - - expect(paramsStream, emits((current: '/home', next: '/home'))); - - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); - - expect( - paramsStream, - emitsInOrder(<({String current, String next})>[ - (current: '/home', next: '/protected'), - (current: '/home', next: '/sign-in') - ]), - ); - router.go('/protected'); - - await tester.pumpAndSettle(); - expect(router.state.uri.toString(), - equals('/sign-in?redirectTo=%2Fprotected')); - - isAuthenticatedResult = true; - // TODO omar: This test is failing because current is still home here - expect( - paramsStream, - emits(( - current: '/sign-in?redirectTo=%2Fprotected', - next: '/protected' - ))); - - await tester.tap(find.byType(ElevatedButton)); - await tester.pumpAndSettle(); - - expect(router.state.uri.toString(), equals('/protected')); - }); - }); } class TestInheritedNotifier extends InheritedNotifier> { diff --git a/packages/go_router/test/on_enter_test.dart b/packages/go_router/test/on_enter_test.dart new file mode 100644 index 00000000000..0c234ffc497 --- /dev/null +++ b/packages/go_router/test/on_enter_test.dart @@ -0,0 +1,901 @@ +// 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. + +// ignore_for_file: cascade_invocations, diagnostic_describe_all_properties, unawaited_futures + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +void main() { + group('onEnter', () { + late GoRouter router; + + tearDown(() { + return Future.delayed(Duration.zero).then((_) => router.dispose()); + }); + + testWidgets( + 'Should set current/next state correctly', + (WidgetTester tester) async { + GoRouterState? capturedCurrentState; + GoRouterState? capturedNextState; + int onEnterCallCount = 0; + + router = GoRouter( + initialLocation: '/', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + onEnterCallCount++; + capturedCurrentState = current; + capturedNextState = next; + return true; + }, + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const Placeholder(), + routes: [ + GoRoute( + path: 'allowed', + builder: (_, __) => const Placeholder(), + ), + GoRoute( + path: 'blocked', + builder: (_, __) => const Placeholder(), + ), + ], + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + expect(onEnterCallCount, equals(1)); + expect( + capturedCurrentState?.uri.path, + capturedNextState?.uri.path, + ); + }, + ); + + testWidgets( + 'Should block navigation when onEnter returns false', + (WidgetTester tester) async { + final List navigationAttempts = []; + String currentPath = '/'; + + router = GoRouter( + initialLocation: '/', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + 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(), + ), + ], + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + final BuildContext context = + tester.element(find.byType(Router)); + final GoRouteInformationParser parser = router.routeInformationParser; + final RouteMatchList beforeBlockedNav = + router.routerDelegate.currentConfiguration; + + // Try blocked route + final RouteMatchList blockedMatch = + await parser.parseRouteInformationWithDependencies( + RouteInformation( + uri: Uri.parse('/blocked'), + state: RouteInformationState(type: NavigatingType.go), + ), + context, + ); + await tester.pumpAndSettle(); + + expect(blockedMatch.uri.toString(), + equals(beforeBlockedNav.uri.toString())); + expect(currentPath, equals('/')); + expect(navigationAttempts, contains('/blocked')); + + // Try allowed route + final RouteMatchList allowedMatch = + await parser.parseRouteInformationWithDependencies( + RouteInformation( + uri: Uri.parse('/allowed'), + state: RouteInformationState(type: NavigatingType.go), + ), + context, + ); + expect(allowedMatch.uri.path, equals('/allowed')); + expect(navigationAttempts, contains('/allowed')); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('Should allow navigation when onEnter returns true', + (WidgetTester tester) async { + int onEnterCallCount = 0; + + router = GoRouter( + initialLocation: '/home', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + onEnterCallCount++; + return !next.uri.path.contains('block'); + }, + routes: [ + GoRoute( + path: '/home', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Home'))), + routes: [ + GoRoute( + path: 'allowed', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Allowed'))), + ), + GoRoute( + path: 'block', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Blocked'))), + ), + ], + ), + ], + redirectLimit: 3, + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + final BuildContext context = tester.element(find.byType(Scaffold)); + final RouteMatchList matchList = await router.routeInformationParser + .parseRouteInformationWithDependencies( + RouteInformation( + uri: Uri.parse('/home/allowed'), + state: RouteInformationState(type: NavigatingType.go), + ), + context, + ); + + expect(matchList.uri.path, equals('/home/allowed')); + expect(onEnterCallCount, greaterThan(0)); + }); + + testWidgets( + 'Should trigger onException when the redirection limit is exceeded', + (WidgetTester tester) async { + final Completer completer = Completer(); + Object? capturedError; + + router = GoRouter( + initialLocation: '/start', + redirectLimit: 2, + onException: + (BuildContext context, GoRouterState state, GoRouter goRouter) { + capturedError = state.error; + goRouter.go('/fallback'); + completer.complete(); + }, + onEnter: (BuildContext context, GoRouterState current, + GoRouterState next, GoRouter goRouter) async { + if (next.uri.path == '/recursive') { + goRouter.push('/recursive'); + return false; + } + return true; + }, + routes: [ + GoRoute( + path: '/start', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Start'))), + ), + GoRoute( + path: '/recursive', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Recursive'))), + ), + GoRoute( + path: '/fallback', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Fallback'))), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + router.go('/recursive'); + await completer.future; + await tester.pumpAndSettle(); + + expect(capturedError, isNotNull); + expect(capturedError.toString(), + contains('Too many onEnter calls detected')); + expect(find.text('Fallback'), findsOneWidget); + }); + + testWidgets('Should handle `go` usage in onEnter', + (WidgetTester tester) async { + bool isAuthenticatedResult = false; + + Future isAuthenticated() => + Future.value(isAuthenticatedResult); + + final StreamController<({String current, String next})> paramsSink = + StreamController<({String current, String next})>(); + final Stream<({String current, String next})> paramsStream = + paramsSink.stream.asBroadcastStream(); + + router = GoRouter( + initialLocation: '/home', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + final bool isProtected = next.uri.toString().contains('protected'); + paramsSink.add( + (current: current.uri.toString(), next: next.uri.toString())); + + if (!isProtected) { + return true; + } + if (await isAuthenticated()) { + return true; + } + router.go('/sign-in'); + return false; + }, + routes: [ + GoRoute( + path: '/home', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Home'))), + ), + GoRoute( + path: '/protected', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Protected'))), + ), + GoRoute( + path: '/sign-in', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Sign-in'))), + ), + ], + ); + + expect(paramsStream, emits((current: '/home', next: '/home'))); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + expect( + paramsStream, + emitsInOrder(<({String current, String next})>[ + (current: '/home', next: '/protected'), + (current: '/home', next: '/sign-in') + ]), + ); + router.go('/protected'); + await tester.pumpAndSettle(); + expect(router.state.uri.toString(), equals('/sign-in')); + + isAuthenticatedResult = true; + expect( + paramsStream, + emits((current: '/sign-in', next: '/protected')), + ); + router.go('/protected'); + await tester.pumpAndSettle(); + + expect(router.state.uri.toString(), equals('/protected')); + await paramsSink.close(); + }); + + testWidgets('Should handle `goNamed` usage in onEnter', + (WidgetTester tester) async { + final List navigationAttempts = []; + + router = GoRouter( + initialLocation: '/home', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + navigationAttempts.add(next.uri.path); + + if (next.uri.path == '/requires-auth') { + goRouter.goNamed('login-page', + queryParameters: {'from': next.uri.toString()}); + return false; + } + return true; + }, + routes: [ + GoRoute( + path: '/home', + builder: (_, __) => const Scaffold( + body: Center(child: Text('Home')), + ), + ), + GoRoute( + path: '/requires-auth', + builder: (_, __) => const Scaffold( + body: Center(child: Text('Authenticated Content')), + ), + ), + GoRoute( + path: '/login', + name: 'login-page', + builder: (_, GoRouterState state) => Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Login Page - From: ${state.uri.queryParameters['from'] ?? 'unknown'}'), + ElevatedButton( + onPressed: () => router.go('/home'), + child: const Text('Go Home'), + ), + ], + ), + ), + ), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + router.go('/requires-auth'); + await tester.pumpAndSettle(); + + expect(navigationAttempts, contains('/requires-auth')); + expect(router.state.uri.path, equals('/login')); + expect(find.text('Login Page - From: /requires-auth'), findsOneWidget); + }); + + testWidgets('Should handle `push` usage in onEnter', + (WidgetTester tester) async { + const bool isAuthenticatedResult = false; + + Future isAuthenticated() => + Future.value(isAuthenticatedResult); + + final StreamController<({String current, String next})> paramsSink = + StreamController<({String current, String next})>(); + final Stream<({String current, String next})> paramsStream = + paramsSink.stream.asBroadcastStream(); + + router = GoRouter( + initialLocation: '/home', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + final bool isProtected = next.uri.toString().contains('protected'); + paramsSink.add( + (current: current.uri.toString(), next: next.uri.toString())); + if (!isProtected) { + return true; + } + if (await isAuthenticated()) { + return true; + } + await router.push('/sign-in').then((bool? isLoggedIn) { + if (isLoggedIn ?? false) { + router.go(next.uri.toString()); + } + }); + + return false; + }, + routes: [ + GoRoute( + path: '/home', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Home'))), + ), + GoRoute( + path: '/protected', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Protected'))), + ), + GoRoute( + path: '/sign-in', + builder: (_, __) => Scaffold( + appBar: AppBar( + title: const Text('Sign in'), + ), + body: const Center(child: Text('Sign-in')), + ), + ), + ], + ); + + expect(paramsStream, emits((current: '/home', next: '/home'))); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + router.go('/protected'); + expect( + paramsStream, + emitsInOrder(<({String current, String next})>[ + (current: '/home', next: '/protected'), + (current: '/home', next: '/sign-in') + ]), + ); + await tester.pumpAndSettle(); + + expect(router.state.uri.toString(), equals('/sign-in')); + expect(find.byType(BackButton), findsOneWidget); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + + expect(router.state.uri.toString(), equals('/home')); + await paramsSink.close(); + }); + + testWidgets('Should handle `replace` usage in onEnter', + (WidgetTester tester) async { + final List navigationHistory = []; + + router = GoRouter( + initialLocation: '/home', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + navigationHistory.add('Entering: ${next.uri.path}'); + + if (next.uri.path == '/old-page') { + navigationHistory.add('Replacing with /new-version'); + await goRouter.replace('/new-version'); + return false; + } + return true; + }, + routes: [ + GoRoute( + path: '/home', + builder: (_, __) => const Scaffold( + body: Center(child: Text('Home')), + ), + ), + GoRoute( + path: '/old-page', + builder: (_, __) => const Scaffold( + body: Center(child: Text('Old Page')), + ), + ), + GoRoute( + path: '/new-version', + builder: (_, __) => const Scaffold( + body: Center(child: Text('New Version')), + ), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + router.go('/old-page'); + await tester.pumpAndSettle(); + + expect(navigationHistory, contains('Entering: /old-page')); + expect(navigationHistory, contains('Replacing with /new-version')); + expect(router.state.uri.path, equals('/new-version')); + expect(find.text('New Version'), findsOneWidget); + + // Verify back behavior works as expected with replace + router.go('/home'); + await tester.pumpAndSettle(); + + expect(find.text('Home'), findsOneWidget); + }); + + testWidgets('Should handle `pushReplacement` usage in onEnter', + (WidgetTester tester) async { + final List navigationLog = []; + + router = GoRouter( + initialLocation: '/home', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + navigationLog.add('Entering: ${next.uri.path}'); + + if (next.uri.path == '/outdated') { + navigationLog.add('Push replacing with /updated'); + await goRouter.pushReplacement('/updated'); + return false; + } + return true; + }, + routes: [ + GoRoute( + path: '/home', + builder: (_, __) => const Scaffold( + body: Center(child: Text('Home')), + ), + ), + GoRoute( + path: '/outdated', + builder: (_, __) => const Scaffold( + body: Center(child: Text('Outdated')), + ), + ), + GoRoute( + path: '/updated', + builder: (_, __) => Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Updated'), + ElevatedButton( + onPressed: () => + router.go('/home'), // Use go instead of pop + child: const Text('Go Home'), + ), + ], + ), + ), + ), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + router.go('/outdated'); + await tester.pumpAndSettle(); + + expect(navigationLog, contains('Entering: /outdated')); + expect(navigationLog, contains('Push replacing with /updated')); + expect(router.state.uri.path, equals('/updated')); + expect(find.text('Updated'), findsOneWidget); + + // Test navigation to home + await tester.tap(find.text('Go Home')); + await tester.pumpAndSettle(); + + // Should now be at home + expect(router.state.uri.path, equals('/home')); + expect(find.text('Home'), findsOneWidget); + }); + + testWidgets('Should allow redirection with query parameters', + (WidgetTester tester) async { + bool isAuthenticatedResult = false; + + Future isAuthenticated() => + Future.value(isAuthenticatedResult); + + final StreamController<({String current, String next})> paramsSink = + StreamController<({String current, String next})>(); + final Stream<({String current, String next})> paramsStream = paramsSink + .stream + .asBroadcastStream(); // Use broadcast for multiple expects + + void goToRedirect(GoRouter router, GoRouterState state) { + final String redirect = state.uri.queryParameters['redirectTo'] ?? ''; + if (redirect.isNotEmpty) { + router.go(Uri.decodeComponent(redirect)); + } else { + router.go('/home'); + } + } + + router = GoRouter( + initialLocation: '/home', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + // Log the attempt + paramsSink.add( + (current: current.uri.toString(), next: next.uri.toString())); + + final bool isProtected = next.uri.path.startsWith('/protected'); + if (!isProtected) { + return true; + } + if (await isAuthenticated()) { + return true; + } + // Use pushNamed as originally intended + await goRouter.pushNamed('sign-in', + queryParameters: { + 'redirectTo': next.uri.toString() + }); + return false; + }, + routes: [ + GoRoute( + path: '/home', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Home'))), + ), + GoRoute( + path: '/protected', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Protected'))), + ), + GoRoute( + path: '/sign-in', + name: 'sign-in', + builder: (_, GoRouterState state) => Scaffold( + appBar: AppBar( + title: const Text('Sign in Title'), + ), + body: Center( + child: ElevatedButton( + child: const Text('Sign in Button'), + onPressed: () => goToRedirect(router, state)), + ), + ), + ), + ], + ); + + // Using expectLater with emitsInOrder covering the whole sequence + unawaited( + // Don't await this expectLater itself + expectLater( + paramsStream, + emitsInOrder([ + // Use dynamic or Matcher type + // 1. Initial Load + equals((current: '/home', next: '/home')), + // 2. Attempt go('/protected') -> onEnter blocks, pushes sign-in + equals((current: '/home', next: '/protected')), + // 3. onEnter runs for the push('/sign-in?...') + equals( + (current: '/home', next: '/sign-in?redirectTo=%2Fprotected')), + // 4. Tap button -> go('/protected') -> onEnter allows + equals(( + current: '/sign-in?redirectTo=%2Fprotected', + next: '/protected' + )), + ]), + ), + ); + + // Initial load + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); // Let initial navigation complete + expect(find.text('Home'), findsOneWidget); + + // Trigger first navigation (go protected -> push sign-in) + router.go('/protected'); + await tester.pumpAndSettle(); // Let navigation to sign-in complete + + // Verify state after first navigation attempt + expect(router.state.uri.toString(), + equals('/sign-in?redirectTo=%2Fprotected')); + expect(find.widgetWithText(ElevatedButton, 'Sign in Button'), + findsOneWidget); + expect(find.byType(BackButton), findsOneWidget); + + // Simulate login + isAuthenticatedResult = true; + + // Trigger second navigation (tap button -> go protected) + await tester.tap(find.widgetWithText(ElevatedButton, 'Sign in Button')); + await tester.pumpAndSettle(); // Let navigation to protected complete + + // Verify final state + expect(router.state.uri.toString(), equals('/protected')); + expect(find.text('Protected'), findsOneWidget); + + // clean up + await paramsSink.close(); + }); + + testWidgets('Should handle sequential navigation steps in onEnter', + (WidgetTester tester) async { + final List navigationChain = []; + final Completer navigationComplete = Completer(); + + router = GoRouter( + initialLocation: '/start', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + final String targetPath = next.uri.path; + navigationChain.add('Entering: $targetPath'); + + // Execute a simpler navigation sequence + if (targetPath == '/multi-step') { + // Step 1: Go to a different route + navigationChain.add('Step 1: Go to /step-one'); + goRouter.go('/step-one'); + + // We're blocking the original navigation + return false; + } + + // When we reach step-one, mark test as complete + if (targetPath == '/step-one') { + navigationComplete.complete(); + } + + return true; + }, + routes: [ + GoRoute( + path: '/start', + builder: (_, __) => const Scaffold( + body: Center(child: Text('Start')), + ), + ), + GoRoute( + path: '/multi-step', + builder: (_, __) => const Scaffold( + body: Center(child: Text('Multi Step')), + ), + ), + GoRoute( + path: '/step-one', + builder: (_, __) => Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Step One'), + ElevatedButton( + onPressed: () => router.go('/start'), + child: const Text('Go Back to Start'), + ), + ], + ), + ), + ), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + // Trigger the navigation sequence + router.go('/multi-step'); + + // Wait for navigation to complete + await navigationComplete.future; + await tester.pumpAndSettle(); + + // Verify the navigation chain steps were executed + expect(navigationChain, contains('Entering: /multi-step')); + expect(navigationChain, contains('Step 1: Go to /step-one')); + expect(navigationChain, contains('Entering: /step-one')); + + // Verify we ended up at the right destination + expect(router.state.uri.path, equals('/step-one')); + expect(find.text('Step One'), findsOneWidget); + + // Test going back to start + await tester.tap(find.text('Go Back to Start')); + await tester.pumpAndSettle(); + + expect(router.state.uri.path, equals('/start')); + expect(find.text('Start'), findsOneWidget); + }); + + testWidgets( + 'Should call onException when exceptions thrown in onEnter callback', + (WidgetTester tester) async { + final Completer completer = Completer(); + Object? capturedError; + + // Set up the router. Note that we short-circuit onEnter for '/fallback' + // to avoid triggering the exception when navigating to the fallback route. + router = GoRouter( + initialLocation: '/error', + onException: + (BuildContext context, GoRouterState state, GoRouter goRouter) { + capturedError = state.error; + // Navigate to a safe fallback route. + goRouter.go('/fallback'); + completer.complete(); + }, + onEnter: (BuildContext context, GoRouterState current, + GoRouterState next, GoRouter goRouter) async { + // If the navigation target is '/fallback', allow it without throwing. + if (next.uri.path == '/fallback') { + return true; + } + // For any other target, throw an exception. + throw Exception('onEnter error triggered'); + }, + routes: [ + GoRoute( + path: '/error', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Error Page'))), + ), + GoRoute( + path: '/fallback', + builder: (_, __) => + const Scaffold(body: Center(child: Text('Fallback Page'))), + ), + ], + ); + + // Build the app with the router. + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + // Since the onEnter callback for '/error' throws, onException should be triggered. + // Wait for the onException handler to complete. + await completer.future; + await tester.pumpAndSettle(); + + // Check that an error was captured and it contains the thrown exception message. + expect(capturedError, isNotNull); + expect(capturedError.toString(), contains('onEnter error triggered')); + // Verify that the fallback route was navigated to. + expect(find.text('Fallback Page'), findsOneWidget); + }); + }); +} From d4f2416c5b6bf45557ab69d0fa895ddeb7417c97 Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Wed, 9 Apr 2025 04:26:20 +0200 Subject: [PATCH 12/27] [go_router] updated Should allow redirection with query parameters test case. --- packages/go_router/test/on_enter_test.dart | 119 +++++++++++++-------- 1 file changed, 77 insertions(+), 42 deletions(-) diff --git a/packages/go_router/test/on_enter_test.dart b/packages/go_router/test/on_enter_test.dart index 0c234ffc497..0c1920c6200 100644 --- a/packages/go_router/test/on_enter_test.dart +++ b/packages/go_router/test/on_enter_test.dart @@ -619,24 +619,31 @@ void main() { expect(find.text('Home'), findsOneWidget); }); - testWidgets('Should allow redirection with query parameters', + testWidgets( + 'onEnter should handle protected route redirection with query parameters', (WidgetTester tester) async { + // Test setup bool isAuthenticatedResult = false; - Future isAuthenticated() => Future.value(isAuthenticatedResult); + // Stream to capture onEnter calls final StreamController<({String current, String next})> paramsSink = StreamController<({String current, String next})>(); - final Stream<({String current, String next})> paramsStream = paramsSink - .stream - .asBroadcastStream(); // Use broadcast for multiple expects + // Use broadcast stream for potentially multiple listeners/expects if needed, + // although expectLater handles one listener well. + final Stream<({String current, String next})> paramsStream = + paramsSink.stream.asBroadcastStream(); + // Helper to navigate after sign-in button press void goToRedirect(GoRouter router, GoRouterState state) { - final String redirect = state.uri.queryParameters['redirectTo'] ?? ''; - if (redirect.isNotEmpty) { + final String? redirect = state.uri.queryParameters['redirectTo']; + // Use null check and Uri.tryParse for safety + if (redirect != null && Uri.tryParse(redirect) != null) { + // Decode potentially encoded URI component router.go(Uri.decodeComponent(redirect)); } else { + // Fallback if redirectTo is missing or invalid router.go('/home'); } } @@ -648,46 +655,57 @@ void main() { GoRouterState current, GoRouterState next, GoRouter goRouter, + // Renamed parameter to avoid shadowing router variable ) async { - // Log the attempt + // Log the navigation attempt state URIs paramsSink.add( (current: current.uri.toString(), next: next.uri.toString())); - final bool isProtected = next.uri.path.startsWith('/protected'); - if (!isProtected) { + final bool isNavigatingToProtected = next.uri.path == '/protected'; + + // Allow navigation if not going to the protected route + if (!isNavigatingToProtected) { return true; } + + // Allow navigation if authenticated if (await isAuthenticated()) { return true; } - // Use pushNamed as originally intended - await goRouter.pushNamed('sign-in', + + // If unauthenticated and going to protected route: + // 1. Redirect to sign-in using pushNamed, passing the intended destination + await goRouter.pushNamed( + 'sign-in', // Return type likely void or not needed queryParameters: { - 'redirectTo': next.uri.toString() + 'redirectTo': next.uri.toString() // Pass the full next URI }); + // 2. Block the original navigation to '/protected' return false; }, routes: [ GoRoute( path: '/home', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Home'))), + name: 'home', // Good practice to name routes + builder: (_, __) => const Scaffold( + body: Center(child: Text('Home Screen'))), // Unique text ), GoRoute( path: '/protected', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Protected'))), + name: 'protected', // Good practice to name routes + builder: (_, __) => const Scaffold( + body: Center(child: Text('Protected Screen'))), // Unique text ), GoRoute( path: '/sign-in', name: 'sign-in', builder: (_, GoRouterState state) => Scaffold( appBar: AppBar( - title: const Text('Sign in Title'), + title: const Text('Sign In Screen Title'), // Unique text ), body: Center( child: ElevatedButton( - child: const Text('Sign in Button'), + child: const Text('Sign In Button'), // Unique text onPressed: () => goToRedirect(router, state)), ), ), @@ -695,57 +713,74 @@ void main() { ], ); - // Using expectLater with emitsInOrder covering the whole sequence + // Expect the stream of onEnter calls to emit events in this specific order + // We use unawaited because expectLater returns a Future that completes + // when the expectation is met or fails, but we want the test execution + // (pumping widgets, triggering actions) to proceed concurrently. unawaited( - // Don't await this expectLater itself expectLater( paramsStream, emitsInOrder([ - // Use dynamic or Matcher type - // 1. Initial Load + // 1. Initial Load to '/home' equals((current: '/home', next: '/home')), - // 2. Attempt go('/protected') -> onEnter blocks, pushes sign-in + // 2. Attempt go('/protected') -> onEnter blocks equals((current: '/home', next: '/protected')), - // 3. onEnter runs for the push('/sign-in?...') + // 3. onEnter runs for the push('/sign-in?redirectTo=...') triggered internally equals( (current: '/home', next: '/sign-in?redirectTo=%2Fprotected')), - // 4. Tap button -> go('/protected') -> onEnter allows + // 4. Tap button -> go('/protected') -> onEnter allows access equals(( - current: '/sign-in?redirectTo=%2Fprotected', + current: + // State when button is tapped + '/sign-in?redirectTo=%2Fprotected', + // Target of the 'go' call next: '/protected' )), ]), ), ); - // Initial load + // Initial widget pump await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); // Let initial navigation complete - expect(find.text('Home'), findsOneWidget); + // Let initial navigation and builds complete + await tester.pumpAndSettle(); + // Verify initial screen + expect(find.text('Home Screen'), findsOneWidget); - // Trigger first navigation (go protected -> push sign-in) + // Trigger navigation to protected route (user is not authenticated) router.go('/protected'); - await tester.pumpAndSettle(); // Let navigation to sign-in complete + // Allow navigation/redirection to complete + await tester.pumpAndSettle(); - // Verify state after first navigation attempt - expect(router.state.uri.toString(), - equals('/sign-in?redirectTo=%2Fprotected')); - expect(find.widgetWithText(ElevatedButton, 'Sign in Button'), + // Verify state after redirection to sign-in + expect( + router.state.uri.toString(), + equals('/sign-in?redirectTo=%2Fprotected'), + ); + // Verify app bar title + expect(find.text('Sign In Screen Title'), findsOneWidget); + // Verify button exists + expect(find.widgetWithText(ElevatedButton, 'Sign In Button'), findsOneWidget); + // BackButton appears because sign-in was pushed onto the stack expect(find.byType(BackButton), findsOneWidget); - // Simulate login + // Simulate successful authentication isAuthenticatedResult = true; - // Trigger second navigation (tap button -> go protected) - await tester.tap(find.widgetWithText(ElevatedButton, 'Sign in Button')); - await tester.pumpAndSettle(); // Let navigation to protected complete + // Trigger navigation back to protected route by tapping the sign-in button + await tester.tap(find.widgetWithText(ElevatedButton, 'Sign In Button')); + // Allow navigation to protected route to complete + await tester.pumpAndSettle(); // Verify final state expect(router.state.uri.toString(), equals('/protected')); - expect(find.text('Protected'), findsOneWidget); + // Verify final screen + expect(find.text('Protected Screen'), findsOneWidget); + // Verify sign-in screen is gone + expect(find.text('Sign In Screen Title'), findsNothing); - // clean up + // Close the stream controller await paramsSink.close(); }); From 757f5a1b0e74abdb54938b36d59e47ac0a53ba75 Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Fri, 30 May 2025 16:46:37 +0300 Subject: [PATCH 13/27] [go_router] Use specific imports in on_enter.dart --- packages/go_router/lib/src/on_enter.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/go_router/lib/src/on_enter.dart b/packages/go_router/lib/src/on_enter.dart index 0b8e205d9ed..dffc9e2c49b 100644 --- a/packages/go_router/lib/src/on_enter.dart +++ b/packages/go_router/lib/src/on_enter.dart @@ -9,7 +9,13 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -import '../go_router.dart'; +import 'configuration.dart'; +import 'information_provider.dart'; +import 'match.dart'; +import 'misc/errors.dart'; +import 'parser.dart'; +import 'router.dart'; +import 'state.dart'; /// The signature for the top-level [onEnter] callback. /// From 4a9e6ff444e5cfc6093f4c568c0d5d19311f4d55 Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Wed, 9 Jul 2025 14:45:02 +0300 Subject: [PATCH 14/27] [go_router] Replace boolean return with sealed class API for onEnter Changes: - Added OnEnterResult sealed class with Allow and Block subclasses - Updated OnEnter typedef to return Future - Modified OnEnterHandler to handle the new return type - Updated all tests and examples to use Allow()/Block() - Exported new classes in go_router.dart --- .../example/lib/top_level_on_enter.dart | 14 +++--- packages/go_router/lib/go_router.dart | 1 + packages/go_router/lib/src/on_enter.dart | 48 ++++++++++++++---- packages/go_router/test/on_enter_test.dart | 50 ++++++++++--------- 4 files changed, 74 insertions(+), 39 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 c56a70c2fc6..82561d1f13c 100644 --- a/packages/go_router/example/lib/top_level_on_enter.dart +++ b/packages/go_router/example/lib/top_level_on_enter.dart @@ -78,8 +78,8 @@ class App extends StatelessWidget { /// Handles incoming routes before navigation occurs. /// This callback can: - /// 1. Block navigation and perform actions (return false) - /// 2. Allow navigation to proceed (return true) + /// 1. Block navigation and perform actions (return Block()) + /// 2. Allow navigation to proceed (return Allow()) /// 3. Show loading states during async operations /// 4. Demonstrate exception handling onEnter: ( @@ -111,15 +111,15 @@ class App extends StatelessWidget { // Process code in background - don't block with complex UI await _processReferralCodeInBackground(context, code); } - return false; // Prevent navigation + return const Block(); // Prevent navigation case '/auth': if (nextState.uri.queryParameters['token'] != null) { _handleAuthToken( context, nextState.uri.queryParameters['token']!); - return false; // Prevent navigation + return const Block(); // Prevent navigation } - return true; + return const Allow(); case '/crash-test': // Deliberately throw an exception to demonstrate error handling @@ -129,10 +129,10 @@ class App extends StatelessWidget { // Runtime type error to test different error types // ignore: unnecessary_cast nextState.uri as int; - return true; + return const Allow(); default: - return true; // Allow navigation for all other routes + return const Allow(); // Allow navigation for all other routes } }, routes: [ diff --git a/packages/go_router/lib/go_router.dart b/packages/go_router/lib/go_router.dart index d2728a077c0..0985bd4aced 100644 --- a/packages/go_router/lib/go_router.dart +++ b/packages/go_router/lib/go_router.dart @@ -14,6 +14,7 @@ export 'src/match.dart' hide RouteMatchListCodec; export 'src/misc/errors.dart'; export 'src/misc/extensions.dart'; export 'src/misc/inherited_router.dart'; +export 'src/on_enter.dart' show Allow, Block, OnEnter, OnEnterResult; export 'src/pages/custom_transition_page.dart'; export 'src/parser.dart'; export 'src/route.dart'; diff --git a/packages/go_router/lib/src/on_enter.dart b/packages/go_router/lib/src/on_enter.dart index dffc9e2c49b..b15eccdf665 100644 --- a/packages/go_router/lib/src/on_enter.dart +++ b/packages/go_router/lib/src/on_enter.dart @@ -17,13 +17,33 @@ import 'parser.dart'; import 'router.dart'; import 'state.dart'; +/// The result of an [onEnter] callback. +/// +/// This sealed class represents the possible outcomes of navigation interception. +sealed class OnEnterResult { + /// Creates an [OnEnterResult]. + const OnEnterResult(); +} + +/// Allows the navigation to proceed. +final class Allow extends OnEnterResult { + /// Creates an [Allow] result. + const Allow(); +} + +/// Blocks the navigation from proceeding. +final class Block extends OnEnterResult { + /// Creates a [Block] result. + const Block(); +} + /// The signature for the top-level [onEnter] callback. /// /// This callback receives the [BuildContext], the current navigation state, /// the state being navigated to, and a reference to the [GoRouter] instance. -/// It returns a [Future] which should resolve to `true` if navigation -/// is allowed, or `false` to block navigation. -typedef OnEnter = Future Function( +/// It returns a [Future] which should resolve to [Allow] if navigation +/// is allowed, or [Block] to block navigation. +typedef OnEnter = Future Function( BuildContext context, GoRouterState currentState, GoRouterState nextState, @@ -80,8 +100,8 @@ class OnEnterHandler { /// configured limit. If everything is within limits, this method builds the current and /// next navigation states, then executes the [onEnter] callback. /// - /// * If [onEnter] returns `true`, the [onCanEnter] callback is invoked to allow navigation. - /// * If [onEnter] returns `false`, the [onCanNotEnter] callback is invoked to block navigation. + /// * If [onEnter] returns [Allow], the [onCanEnter] callback is invoked to allow navigation. + /// * If [onEnter] returns [Block], the [onCanNotEnter] callback is invoked to block navigation. /// /// Exceptions thrown synchronously or asynchronously by [onEnter] are caught and processed /// via the [_onParserException] handler if available. @@ -127,9 +147,9 @@ class OnEnterHandler { : nextState; // Execute the onEnter callback in a try-catch to capture synchronous exceptions. - Future canEnterFuture; + Future onEnterResultFuture; try { - canEnterFuture = topOnEnter( + onEnterResultFuture = topOnEnter( context, currentState, nextState, @@ -153,8 +173,18 @@ class OnEnterHandler { _resetRedirectionHistory(); // Handle asynchronous completion and catch any errors. - return canEnterFuture.then( - (bool canEnter) => canEnter ? onCanEnter() : onCanNotEnter(), + return onEnterResultFuture.then( + (OnEnterResult result) { + if (result is Allow) { + return onCanEnter(); + } else if (result is Block) { + return onCanNotEnter(); + } else { + // This should never happen with a sealed class, but provide a fallback + throw GoException( + 'Invalid OnEnterResult type: ${result.runtimeType}'); + } + }, onError: (Object error, StackTrace stackTrace) { final RouteMatchList errorMatchList = _errorRouteMatchList( routeInformation.uri, diff --git a/packages/go_router/test/on_enter_test.dart b/packages/go_router/test/on_enter_test.dart index 0c1920c6200..5c46c71e618 100644 --- a/packages/go_router/test/on_enter_test.dart +++ b/packages/go_router/test/on_enter_test.dart @@ -36,7 +36,7 @@ void main() { onEnterCallCount++; capturedCurrentState = current; capturedNextState = next; - return true; + return const Allow(); }, routes: [ GoRoute( @@ -83,7 +83,9 @@ void main() { ) async { navigationAttempts.add(next.uri.path); currentPath = current.uri.path; - return !next.uri.path.contains('blocked'); + return next.uri.path.contains('blocked') + ? const Block() + : const Allow(); }, routes: [ GoRoute( @@ -156,7 +158,9 @@ void main() { GoRouter goRouter, ) async { onEnterCallCount++; - return !next.uri.path.contains('block'); + return next.uri.path.contains('block') + ? const Block() + : const Allow(); }, routes: [ GoRoute( @@ -216,9 +220,9 @@ void main() { GoRouterState next, GoRouter goRouter) async { if (next.uri.path == '/recursive') { goRouter.push('/recursive'); - return false; + return const Block(); } - return true; + return const Allow(); }, routes: [ GoRoute( @@ -277,13 +281,13 @@ void main() { (current: current.uri.toString(), next: next.uri.toString())); if (!isProtected) { - return true; + return const Allow(); } if (await isAuthenticated()) { - return true; + return const Allow(); } router.go('/sign-in'); - return false; + return const Block(); }, routes: [ GoRoute( @@ -349,9 +353,9 @@ void main() { if (next.uri.path == '/requires-auth') { goRouter.goNamed('login-page', queryParameters: {'from': next.uri.toString()}); - return false; + return const Block(); } - return true; + return const Allow(); }, routes: [ GoRoute( @@ -423,10 +427,10 @@ void main() { paramsSink.add( (current: current.uri.toString(), next: next.uri.toString())); if (!isProtected) { - return true; + return const Allow(); } if (await isAuthenticated()) { - return true; + return const Allow(); } await router.push('/sign-in').then((bool? isLoggedIn) { if (isLoggedIn ?? false) { @@ -434,7 +438,7 @@ void main() { } }); - return false; + return const Block(); }, routes: [ GoRoute( @@ -500,9 +504,9 @@ void main() { if (next.uri.path == '/old-page') { navigationHistory.add('Replacing with /new-version'); await goRouter.replace('/new-version'); - return false; + return const Block(); } - return true; + return const Allow(); }, routes: [ GoRoute( @@ -561,9 +565,9 @@ void main() { if (next.uri.path == '/outdated') { navigationLog.add('Push replacing with /updated'); await goRouter.pushReplacement('/updated'); - return false; + return const Block(); } - return true; + return const Allow(); }, routes: [ GoRoute( @@ -665,12 +669,12 @@ void main() { // Allow navigation if not going to the protected route if (!isNavigatingToProtected) { - return true; + return const Allow(); } // Allow navigation if authenticated if (await isAuthenticated()) { - return true; + return const Allow(); } // If unauthenticated and going to protected route: @@ -681,7 +685,7 @@ void main() { 'redirectTo': next.uri.toString() // Pass the full next URI }); // 2. Block the original navigation to '/protected' - return false; + return const Block(); }, routes: [ GoRoute( @@ -807,7 +811,7 @@ void main() { goRouter.go('/step-one'); // We're blocking the original navigation - return false; + return const Block(); } // When we reach step-one, mark test as complete @@ -815,7 +819,7 @@ void main() { navigationComplete.complete(); } - return true; + return const Allow(); }, routes: [ GoRoute( @@ -898,7 +902,7 @@ void main() { GoRouterState next, GoRouter goRouter) async { // If the navigation target is '/fallback', allow it without throwing. if (next.uri.path == '/fallback') { - return true; + return const Allow(); } // For any other target, throw an exception. throw Exception('onEnter error triggered'); From 3b2df496c92a4165464f0cae9ea87385ee72bf4e Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Sat, 2 Aug 2025 12:07:35 +0300 Subject: [PATCH 15/27] [go_router] Make OnEnterHandler private and ensure onEnter priority over redirect --- packages/go_router/lib/src/configuration.dart | 2 + packages/go_router/lib/src/on_enter.dart | 262 ----------------- packages/go_router/lib/src/parser.dart | 270 +++++++++++++++++- packages/go_router/lib/src/router.dart | 13 +- packages/go_router/test/on_enter_test.dart | 124 ++++++++ 5 files changed, 399 insertions(+), 272 deletions(-) diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index e679ae42107..4f1aaec132a 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -255,6 +255,8 @@ class RouteConfiguration { /// topic. /// * [extra_codec](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/extra_codec.dart) /// example. + /// * [topOnEnter] for navigation interception. + /// * [topRedirect] (deprecated) for legacy redirections. final Codec? extraCodec; final Map _nameToPath = {}; diff --git a/packages/go_router/lib/src/on_enter.dart b/packages/go_router/lib/src/on_enter.dart index b15eccdf665..139831a8aa4 100644 --- a/packages/go_router/lib/src/on_enter.dart +++ b/packages/go_router/lib/src/on_enter.dart @@ -6,14 +6,8 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -import 'configuration.dart'; -import 'information_provider.dart'; -import 'match.dart'; -import 'misc/errors.dart'; -import 'parser.dart'; import 'router.dart'; import 'state.dart'; @@ -49,259 +43,3 @@ typedef OnEnter = Future Function( GoRouterState nextState, GoRouter goRouter, ); - -/// Handles the top-level [onEnter] callback logic and manages redirection history. -/// -/// This class encapsulates the logic to execute the top-level [onEnter] callback, -/// enforce the redirection limit defined in the router configuration, and generate -/// an error match list when the limit is exceeded. It is used internally by [GoRouter] -/// during route parsing. -class OnEnterHandler { - /// Creates an [OnEnterHandler] instance. - /// - /// * [configuration] is the current route configuration containing all route definitions. - /// * [router] is the [GoRouter] instance used for navigation actions. - /// * [onParserException] is an optional exception handler invoked on route parsing errors. - OnEnterHandler({ - required RouteConfiguration configuration, - required GoRouter router, - required ParserExceptionHandler? onParserException, - }) : _onParserException = onParserException, - _configuration = configuration, - _router = router; - - /// The current route configuration. - /// - /// Contains all route definitions, redirection logic, and navigation settings. - final RouteConfiguration _configuration; - - /// Optional exception handler for route parsing errors. - /// - /// This handler is invoked when errors occur during route parsing (for example, - /// when the [onEnter] redirection limit is exceeded) to return a fallback [RouteMatchList]. - final ParserExceptionHandler? _onParserException; - - /// The [GoRouter] instance used to perform navigation actions. - /// - /// This provides access to the imperative navigation methods (like [go], [push], - /// [replace], etc.) and serves as a fallback reference in case the [BuildContext] - /// does not include a [GoRouter]. - final GoRouter _router; - - /// A history of URIs encountered during [onEnter] redirections. - /// - /// This list tracks every URI that triggers an [onEnter] redirection, ensuring that - /// the number of redirections does not exceed the limit defined in the router's configuration. - final List _redirectionHistory = []; - - /// Executes the top-level [onEnter] callback and determines whether navigation should proceed. - /// - /// It checks for redirection errors by verifying if the redirection history exceeds the - /// configured limit. If everything is within limits, this method builds the current and - /// next navigation states, then executes the [onEnter] callback. - /// - /// * If [onEnter] returns [Allow], the [onCanEnter] callback is invoked to allow navigation. - /// * If [onEnter] returns [Block], the [onCanNotEnter] callback is invoked to block navigation. - /// - /// Exceptions thrown synchronously or asynchronously by [onEnter] are caught and processed - /// via the [_onParserException] handler if available. - /// - /// Returns a [Future] representing the final navigation state. - Future handleTopOnEnter({ - required BuildContext context, - required RouteInformation routeInformation, - required RouteInformationState infoState, - required Future Function() onCanEnter, - required Future Function() onCanNotEnter, - }) { - final OnEnter? topOnEnter = _configuration.topOnEnter; - // If no onEnter is configured, allow navigation immediately. - if (topOnEnter == null) { - return onCanEnter(); - } - - // Check if the redirection history exceeds the configured limit. - final RouteMatchList? redirectionErrorMatchList = - _redirectionErrorMatchList(context, routeInformation.uri, infoState); - - if (redirectionErrorMatchList != null) { - // Return immediately if the redirection limit is exceeded. - return SynchronousFuture(redirectionErrorMatchList); - } - - // Find route matches for the incoming URI. - final RouteMatchList incomingMatches = _configuration.findMatch( - routeInformation.uri, - extra: infoState.extra, - ); - - // Build the next navigation state. - final GoRouterState nextState = - _buildTopLevelGoRouterState(incomingMatches); - - // Get the current state from the router delegate. - final RouteMatchList currentMatchList = - _router.routerDelegate.currentConfiguration; - final GoRouterState currentState = currentMatchList.isNotEmpty - ? _buildTopLevelGoRouterState(currentMatchList) - : nextState; - - // Execute the onEnter callback in a try-catch to capture synchronous exceptions. - Future onEnterResultFuture; - try { - onEnterResultFuture = topOnEnter( - context, - currentState, - nextState, - _router, - ); - } catch (error) { - final RouteMatchList errorMatchList = _errorRouteMatchList( - routeInformation.uri, - error is GoException ? error : GoException(error.toString()), - extra: infoState.extra, - ); - - _resetRedirectionHistory(); - - return SynchronousFuture(_onParserException != null - ? _onParserException(context, errorMatchList) - : errorMatchList); - } - - // Reset the redirection history after attempting the callback. - _resetRedirectionHistory(); - - // Handle asynchronous completion and catch any errors. - return onEnterResultFuture.then( - (OnEnterResult result) { - if (result is Allow) { - return onCanEnter(); - } else if (result is Block) { - return onCanNotEnter(); - } else { - // This should never happen with a sealed class, but provide a fallback - throw GoException( - 'Invalid OnEnterResult type: ${result.runtimeType}'); - } - }, - onError: (Object error, StackTrace stackTrace) { - final RouteMatchList errorMatchList = _errorRouteMatchList( - routeInformation.uri, - error is GoException ? error : GoException(error.toString()), - extra: infoState.extra, - ); - - return _onParserException != null - ? _onParserException(context, errorMatchList) - : errorMatchList; - }, - ); - } - - /// Builds a [GoRouterState] based on the given [matchList]. - /// - /// This method derives the effective URI, full path, path parameters, and extra data from - /// the topmost route match, drilling down through nested shells if necessary. - /// - /// Returns a constructed [GoRouterState] reflecting the current or next navigation state. - GoRouterState _buildTopLevelGoRouterState(RouteMatchList matchList) { - // Determine effective navigation state from the match list. - Uri effectiveUri = matchList.uri; - String? effectiveFullPath = matchList.fullPath; - Map effectivePathParams = matchList.pathParameters; - String effectiveMatchedLocation = matchList.uri.path; - Object? effectiveExtra = matchList.extra; // Base extra - - if (matchList.matches.isNotEmpty) { - RouteMatchBase lastMatch = matchList.matches.last; - // Drill down to the actual leaf match even inside shell routes. - while (lastMatch is ShellRouteMatch) { - if (lastMatch.matches.isEmpty) { - break; - } - lastMatch = lastMatch.matches.last; - } - - if (lastMatch is ImperativeRouteMatch) { - // Use state from the imperative match. - effectiveUri = lastMatch.matches.uri; - effectiveFullPath = lastMatch.matches.fullPath; - effectivePathParams = lastMatch.matches.pathParameters; - effectiveMatchedLocation = lastMatch.matches.uri.path; - effectiveExtra = lastMatch.matches.extra; - } else { - // For non-imperative matches, use the matched location and extra from the match list. - effectiveMatchedLocation = lastMatch.matchedLocation; - effectiveExtra = matchList.extra; - } - } - - return GoRouterState( - _configuration, - uri: effectiveUri, - matchedLocation: effectiveMatchedLocation, - name: matchList.lastOrNull?.route.name, - path: matchList.lastOrNull?.route.path, - fullPath: effectiveFullPath, - pathParameters: effectivePathParams, - extra: effectiveExtra, - pageKey: const ValueKey('topLevel'), - topRoute: matchList.lastOrNull?.route, - error: matchList.error, - ); - } - - /// Processes the redirection history and checks against the configured redirection limit. - /// - /// Adds [redirectedUri] to the history and, if the limit is exceeded, returns an error - /// match list. Otherwise, returns null. - RouteMatchList? _redirectionErrorMatchList( - BuildContext context, - Uri redirectedUri, - RouteInformationState infoState, - ) { - _redirectionHistory.add(redirectedUri); - if (_redirectionHistory.length > _configuration.redirectLimit) { - final String formattedHistory = - _formatOnEnterRedirectionHistory(_redirectionHistory); - final RouteMatchList errorMatchList = _errorRouteMatchList( - redirectedUri, - GoException('Too many onEnter calls detected: $formattedHistory'), - extra: infoState.extra, - ); - _resetRedirectionHistory(); - return _onParserException != null - ? _onParserException(context, errorMatchList) - : errorMatchList; - } - return null; - } - - /// Clears the redirection history. - void _resetRedirectionHistory() { - _redirectionHistory.clear(); - } - - /// Formats the redirection history into a string for error reporting. - String _formatOnEnterRedirectionHistory(List history) { - return history.map((Uri uri) => uri.toString()).join(' => '); - } - - /// Creates an error [RouteMatchList] for the given [uri] and [exception]. - /// - /// This is used to encapsulate errors encountered during redirection or parsing. - static RouteMatchList _errorRouteMatchList( - Uri uri, - GoException exception, { - Object? extra, - }) { - return RouteMatchList( - matches: const [], - extra: extra, - error: exception, - uri: uri, - pathParameters: const {}, - ); - } -} diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index 6124b46b034..7d90002d32b 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -13,7 +13,6 @@ import 'package:flutter/widgets.dart'; import '../go_router.dart'; import 'logging.dart'; import 'match.dart'; -import 'on_enter.dart'; /// The function signature of [GoRouteInformationParser.onParserException]. /// @@ -40,7 +39,7 @@ class GoRouteInformationParser extends RouteInformationParser { required this.onParserException, }) : _routeMatchListCodec = RouteMatchListCodec(configuration), _initialLocation = initialLocation, - _onEnterHandler = OnEnterHandler( + _onEnterHandler = _OnEnterHandler( configuration: configuration, router: router, onParserException: onParserException, @@ -59,8 +58,8 @@ class GoRouteInformationParser extends RouteInformationParser { /// Stores the last successful match list to enable "stay" on the same route. RouteMatchList? _lastMatchList; - /// Instance of [OnEnterHandler] to process top-level onEnter logic. - final OnEnterHandler _onEnterHandler; + /// Instance of [_OnEnterHandler] to process top-level onEnter logic. + final _OnEnterHandler _onEnterHandler; /// The future of current route parsing (used for testing asynchronous redirection). @visibleForTesting @@ -117,6 +116,9 @@ class GoRouteInformationParser extends RouteInformationParser { /// Normalizes the URI, finds matching routes, processes redirects, and updates /// the route match list based on the navigation type. + /// + /// This method is called ONLY AFTER onEnter has allowed the navigation. + /// It includes the deprecated redirect logic. Future _navigate( RouteInformation routeInformation, BuildContext context, @@ -139,7 +141,7 @@ class GoRouteInformationParser extends RouteInformationParser { log('No initial matches: ${routeInformation.uri.path}'); } - // Process any defined redirects. + // Process the deprecated redirect AFTER onEnter has allowed navigation return debugParserFuture = _redirect(context, initialMatches).then((RouteMatchList matchList) { if (matchList.isError && onParserException != null) { @@ -285,3 +287,261 @@ class GoRouteInformationParser extends RouteInformationParser { ); } } + +/// Handles the top-level [onEnter] callback logic and manages redirection history. +/// +/// This class encapsulates the logic to execute the top-level [onEnter] callback, +/// enforce the redirection limit defined in the router configuration, and generate +/// an error match list when the limit is exceeded. It is used internally by [GoRouter] +/// during route parsing. +class _OnEnterHandler { + /// Creates an [_OnEnterHandler] instance. + /// + /// * [configuration] is the current route configuration containing all route definitions. + /// * [router] is the [GoRouter] instance used for navigation actions. + /// * [onParserException] is an optional exception handler invoked on route parsing errors. + _OnEnterHandler({ + required RouteConfiguration configuration, + required GoRouter router, + required ParserExceptionHandler? onParserException, + }) : _onParserException = onParserException, + _configuration = configuration, + _router = router; + + /// The current route configuration. + /// + /// Contains all route definitions, redirection logic, and navigation settings. + final RouteConfiguration _configuration; + + /// Optional exception handler for route parsing errors. + /// + /// This handler is invoked when errors occur during route parsing (for example, + /// when the [onEnter] redirection limit is exceeded) to return a fallback [RouteMatchList]. + final ParserExceptionHandler? _onParserException; + + /// The [GoRouter] instance used to perform navigation actions. + /// + /// This provides access to the imperative navigation methods (like [go], [push], + /// [replace], etc.) and serves as a fallback reference in case the [BuildContext] + /// does not include a [GoRouter]. + final GoRouter _router; + + /// A history of URIs encountered during [onEnter] redirections. + /// + /// This list tracks every URI that triggers an [onEnter] redirection, ensuring that + /// the number of redirections does not exceed the limit defined in the router's configuration. + final List _redirectionHistory = []; + + /// Executes the top-level [onEnter] callback and determines whether navigation should proceed. + /// + /// It checks for redirection errors by verifying if the redirection history exceeds the + /// configured limit. If everything is within limits, this method builds the current and + /// next navigation states, then executes the [onEnter] callback. + /// + /// * If [onEnter] returns [Allow], the [onCanEnter] callback is invoked to allow navigation. + /// * If [onEnter] returns [Block], the [onCanNotEnter] callback is invoked to block navigation. + /// + /// Exceptions thrown synchronously or asynchronously by [onEnter] are caught and processed + /// via the [_onParserException] handler if available. + /// + /// Returns a [Future] representing the final navigation state. + Future handleTopOnEnter({ + required BuildContext context, + required RouteInformation routeInformation, + required RouteInformationState infoState, + required Future Function() onCanEnter, + required Future Function() onCanNotEnter, + }) { + final OnEnter? topOnEnter = _configuration.topOnEnter; + // If no onEnter is configured, allow navigation immediately. + if (topOnEnter == null) { + return onCanEnter(); + } + + // Check if the redirection history exceeds the configured limit. + final RouteMatchList? redirectionErrorMatchList = + _redirectionErrorMatchList(context, routeInformation.uri, infoState); + + if (redirectionErrorMatchList != null) { + // Return immediately if the redirection limit is exceeded. + return SynchronousFuture(redirectionErrorMatchList); + } + + // Find route matches for the incoming URI. + final RouteMatchList incomingMatches = _configuration.findMatch( + routeInformation.uri, + extra: infoState.extra, + ); + + // Build the next navigation state. + final GoRouterState nextState = + _buildTopLevelGoRouterState(incomingMatches); + + // Get the current state from the router delegate. + final RouteMatchList currentMatchList = + _router.routerDelegate.currentConfiguration; + final GoRouterState currentState = currentMatchList.isNotEmpty + ? _buildTopLevelGoRouterState(currentMatchList) + : nextState; + + // Execute the onEnter callback in a try-catch to capture synchronous exceptions. + Future onEnterResultFuture; + try { + onEnterResultFuture = topOnEnter( + context, + currentState, + nextState, + _router, + ); + } catch (error) { + final RouteMatchList errorMatchList = _errorRouteMatchList( + routeInformation.uri, + error is GoException ? error : GoException(error.toString()), + extra: infoState.extra, + ); + + _resetRedirectionHistory(); + + return SynchronousFuture(_onParserException != null + ? _onParserException(context, errorMatchList) + : errorMatchList); + } + + // Reset the redirection history after attempting the callback. + _resetRedirectionHistory(); + + // Handle asynchronous completion and catch any errors. + return onEnterResultFuture.then( + (OnEnterResult result) { + if (result is Allow) { + return onCanEnter(); + } else if (result is Block) { + // Add logging for blocked navigation + log('onEnter blocked navigation from ${currentState.uri} to ${nextState.uri}'); + return onCanNotEnter(); + } else { + // This should never happen with a sealed class, but provide a fallback + throw GoException( + 'Invalid OnEnterResult type: ${result.runtimeType}'); + } + }, + onError: (Object error, StackTrace stackTrace) { + final RouteMatchList errorMatchList = _errorRouteMatchList( + routeInformation.uri, + error is GoException ? error : GoException(error.toString()), + extra: infoState.extra, + ); + + return _onParserException != null + ? _onParserException(context, errorMatchList) + : errorMatchList; + }, + ); + } + + /// Builds a [GoRouterState] based on the given [matchList]. + /// + /// This method derives the effective URI, full path, path parameters, and extra data from + /// the topmost route match, drilling down through nested shells if necessary. + /// + /// Returns a constructed [GoRouterState] reflecting the current or next navigation state. + GoRouterState _buildTopLevelGoRouterState(RouteMatchList matchList) { + // Determine effective navigation state from the match list. + Uri effectiveUri = matchList.uri; + String? effectiveFullPath = matchList.fullPath; + Map effectivePathParams = matchList.pathParameters; + String effectiveMatchedLocation = matchList.uri.path; + Object? effectiveExtra = matchList.extra; // Base extra + + if (matchList.matches.isNotEmpty) { + RouteMatchBase lastMatch = matchList.matches.last; + // Drill down to the actual leaf match even inside shell routes. + while (lastMatch is ShellRouteMatch) { + if (lastMatch.matches.isEmpty) { + break; + } + lastMatch = lastMatch.matches.last; + } + + if (lastMatch is ImperativeRouteMatch) { + // Use state from the imperative match. + effectiveUri = lastMatch.matches.uri; + effectiveFullPath = lastMatch.matches.fullPath; + effectivePathParams = lastMatch.matches.pathParameters; + effectiveMatchedLocation = lastMatch.matches.uri.path; + effectiveExtra = lastMatch.matches.extra; + } else { + // For non-imperative matches, use the matched location and extra from the match list. + effectiveMatchedLocation = lastMatch.matchedLocation; + effectiveExtra = matchList.extra; + } + } + + return GoRouterState( + _configuration, + uri: effectiveUri, + matchedLocation: effectiveMatchedLocation, + name: matchList.lastOrNull?.route.name, + path: matchList.lastOrNull?.route.path, + fullPath: effectiveFullPath, + pathParameters: effectivePathParams, + extra: effectiveExtra, + pageKey: const ValueKey('topLevel'), + topRoute: matchList.lastOrNull?.route, + error: matchList.error, + ); + } + + /// Processes the redirection history and checks against the configured redirection limit. + /// + /// Adds [redirectedUri] to the history and, if the limit is exceeded, returns an error + /// match list. Otherwise, returns null. + RouteMatchList? _redirectionErrorMatchList( + BuildContext context, + Uri redirectedUri, + RouteInformationState infoState, + ) { + _redirectionHistory.add(redirectedUri); + if (_redirectionHistory.length > _configuration.redirectLimit) { + final String formattedHistory = + _formatOnEnterRedirectionHistory(_redirectionHistory); + final RouteMatchList errorMatchList = _errorRouteMatchList( + redirectedUri, + GoException('Too many onEnter calls detected: $formattedHistory'), + extra: infoState.extra, + ); + _resetRedirectionHistory(); + return _onParserException != null + ? _onParserException(context, errorMatchList) + : errorMatchList; + } + return null; + } + + /// Clears the redirection history. + void _resetRedirectionHistory() { + _redirectionHistory.clear(); + } + + /// Formats the redirection history into a string for error reporting. + String _formatOnEnterRedirectionHistory(List history) { + return history.map((Uri uri) => uri.toString()).join(' => '); + } + + /// Creates an error [RouteMatchList] for the given [uri] and [exception]. + /// + /// This is used to encapsulate errors encountered during redirection or parsing. + static RouteMatchList _errorRouteMatchList( + Uri uri, + GoException exception, { + Object? extra, + }) { + return RouteMatchList( + matches: const [], + extra: extra, + error: exception, + uri: uri, + pathParameters: const {}, + ); + } +} diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index 306b5b53da9..7df0deca16c 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -73,8 +73,8 @@ class RoutingConfig { /// /// See [GoRouter]. @Deprecated( - 'Use onEnter instead. ' - 'This feature will be removed in a future release.', + 'Use onEnter for redirection. In the onEnter callback, call a navigation ' + 'method like router.go() and return const Block(). ', ) final GoRouterRedirect redirect; @@ -122,6 +122,10 @@ class RoutingConfig { /// started](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/main.dart) /// example, which shows an app with a simple route configuration. /// +/// The [onEnter] callback allows intercepting navigation before routes are +/// processed. Return [Allow] to proceed or [Block] to prevent navigation. +/// This runs before the deprecated [redirect] callback. +/// /// The [redirect] callback allows the app to redirect to a new location. /// Alternatively, you can specify a redirect for an individual route using /// [GoRoute.redirect]. If [BuildContext.dependOnInheritedWidgetOfExactType] is @@ -167,9 +171,8 @@ class GoRouter implements RouterConfig { GoRouterPageBuilder? errorPageBuilder, GoRouterWidgetBuilder? errorBuilder, @Deprecated( - 'Use onEnter instead. ' - 'This feature will be removed in a future release.', - ) + 'Use onEnter for redirection. In the onEnter callback, call a navigation ' + 'method like router.go() and return const Block(). ') GoRouterRedirect? redirect, int redirectLimit = 5, Listenable? refreshListenable, diff --git a/packages/go_router/test/on_enter_test.dart b/packages/go_router/test/on_enter_test.dart index 5c46c71e618..0e0028ce5a3 100644 --- a/packages/go_router/test/on_enter_test.dart +++ b/packages/go_router/test/on_enter_test.dart @@ -936,5 +936,129 @@ void main() { // Verify that the fallback route was navigated to. expect(find.text('Fallback Page'), findsOneWidget); }); + + testWidgets('onEnter has priority over deprecated redirect', + (WidgetTester tester) async { + int redirectCallCount = 0; + int onEnterCallCount = 0; + bool lastOnEnterBlocked = false; + + router = GoRouter( + initialLocation: '/start', + routes: [ + GoRoute( + path: '/start', + builder: (_, __) => const Text('Start'), + ), + GoRoute( + path: '/blocked', + builder: (_, __) => const Text('Blocked'), + ), + GoRoute( + path: '/allowed', + builder: (_, __) => const Text('Allowed'), + ), + ], + onEnter: (_, __, GoRouterState next, ___) async { + onEnterCallCount++; + lastOnEnterBlocked = next.uri.path == '/blocked'; + if (lastOnEnterBlocked) { + return const Block(); + } + return const Allow(); + }, + // ignore: deprecated_member_use_from_same_package + redirect: (_, GoRouterState state) { + redirectCallCount++; + // This should never be called for /blocked + return null; + }, + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + // Record initial counts + final int initialRedirectCount = redirectCallCount; + final int initialOnEnterCount = onEnterCallCount; + + // Test blocked route + router.go('/blocked'); + await tester.pumpAndSettle(); + + expect(onEnterCallCount, greaterThan(initialOnEnterCount)); + expect( + redirectCallCount, + equals( + initialRedirectCount)); // redirect should not be called for blocked routes + expect(find.text('Start'), findsOneWidget); // Should stay on start + expect(lastOnEnterBlocked, isTrue); + + // Test allowed route + final int beforeAllowedRedirectCount = redirectCallCount; + router.go('/allowed'); + await tester.pumpAndSettle(); + + expect(onEnterCallCount, greaterThan(initialOnEnterCount + 1)); + expect( + redirectCallCount, + greaterThan( + beforeAllowedRedirectCount)); // redirect should be called this time + expect(find.text('Allowed'), findsOneWidget); + }); + + testWidgets('onEnter blocks navigation and preserves current route', + (WidgetTester tester) async { + String? capturedCurrentPath; + String? capturedNextPath; + + router = GoRouter( + initialLocation: '/page1', + routes: [ + GoRoute( + path: '/page1', + builder: (_, __) => const Text('Page 1'), + ), + GoRoute( + path: '/page2', + builder: (_, __) => const Text('Page 2'), + ), + GoRoute( + path: '/protected', + builder: (_, __) => const Text('Protected'), + ), + ], + onEnter: (_, GoRouterState current, GoRouterState next, ___) async { + capturedCurrentPath = current.uri.path; + capturedNextPath = next.uri.path; + + if (next.uri.path == '/protected') { + return const Block(); + } + return const Allow(); + }, + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + expect(find.text('Page 1'), findsOneWidget); + + // Navigate to page2 (allowed) + router.go('/page2'); + await tester.pumpAndSettle(); + expect(find.text('Page 2'), findsOneWidget); + expect(capturedCurrentPath, equals('/page1')); + expect(capturedNextPath, equals('/page2')); + + // Try to navigate to protected (blocked) + router.go('/protected'); + await tester.pumpAndSettle(); + + // Should stay on page2 + expect(find.text('Page 2'), findsOneWidget); + expect(find.text('Protected'), findsNothing); + expect(capturedCurrentPath, equals('/page2')); + expect(capturedNextPath, equals('/protected')); + }); }); } From d0d5e6da2ec1c33337823f9c4d884922c13083ad Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Tue, 19 Aug 2025 17:00:59 +0300 Subject: [PATCH 16/27] [go_router] added allow/block factories for the OnEnterResult --- packages/go_router/example/lib/top_level_on_enter.dart | 7 ++++--- packages/go_router/lib/src/on_enter.dart | 6 ++++++ 2 files changed, 10 insertions(+), 3 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 82561d1f13c..d96749b174a 100644 --- a/packages/go_router/example/lib/top_level_on_enter.dart +++ b/packages/go_router/example/lib/top_level_on_enter.dart @@ -119,7 +119,7 @@ class App extends StatelessWidget { context, nextState.uri.queryParameters['token']!); return const Block(); // Prevent navigation } - return const Allow(); + return const OnEnterResult.allow(); case '/crash-test': // Deliberately throw an exception to demonstrate error handling @@ -129,10 +129,11 @@ class App extends StatelessWidget { // Runtime type error to test different error types // ignore: unnecessary_cast nextState.uri as int; - return const Allow(); + return const OnEnterResult.allow(); default: - return const Allow(); // Allow navigation for all other routes + // Allow navigation for all other routes + return const OnEnterResult.allow(); } }, routes: [ diff --git a/packages/go_router/lib/src/on_enter.dart b/packages/go_router/lib/src/on_enter.dart index 139831a8aa4..b22d30e845b 100644 --- a/packages/go_router/lib/src/on_enter.dart +++ b/packages/go_router/lib/src/on_enter.dart @@ -17,6 +17,12 @@ import 'state.dart'; sealed class OnEnterResult { /// Creates an [OnEnterResult]. const OnEnterResult(); + + /// Creates an [Allow] result that allows navigation to proceed. + const factory OnEnterResult.allow() = Allow; + + /// Creates a [Block] result that blocks navigation from proceeding. + const factory OnEnterResult.block() = Block; } /// Allows the navigation to proceed. From a48496594748a45bad8f4abcfce2fe7e8a851e7c Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Tue, 19 Aug 2025 17:02:22 +0300 Subject: [PATCH 17/27] [go_router] ran dart format --- .../example/lib/top_level_on_enter.dart | 189 ++-- packages/go_router/lib/src/configuration.dart | 6 +- packages/go_router/lib/src/on_enter.dart | 13 +- packages/go_router/lib/src/parser.dart | 78 +- packages/go_router/lib/src/router.dart | 5 +- packages/go_router/test/on_enter_test.dart | 966 +++++++++--------- 6 files changed, 644 insertions(+), 613 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 d96749b174a..5cbd43d6355 100644 --- a/packages/go_router/example/lib/top_level_on_enter.dart +++ b/packages/go_router/example/lib/top_level_on_enter.dart @@ -36,10 +36,7 @@ class App extends StatelessWidget { return MaterialApp.router( routerConfig: _router(key), title: 'Top-level onEnter', - theme: ThemeData( - useMaterial3: true, - primarySwatch: Colors.blue, - ), + theme: ThemeData(useMaterial3: true, primarySwatch: Colors.blue), ); } @@ -51,8 +48,11 @@ class App extends StatelessWidget { debugLogDiagnostics: true, /// Exception handler to gracefully handle errors in navigation - onException: - (BuildContext context, GoRouterState state, GoRouter router) { + onException: ( + BuildContext context, + GoRouterState state, + GoRouter router, + ) { // Show a user-friendly error message if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -116,7 +116,9 @@ class App extends StatelessWidget { case '/auth': if (nextState.uri.queryParameters['token'] != null) { _handleAuthToken( - context, nextState.uri.queryParameters['token']!); + context, + nextState.uri.queryParameters['token']!, + ); return const Block(); // Prevent navigation } return const OnEnterResult.allow(); @@ -143,39 +145,45 @@ class App extends StatelessWidget { ), GoRoute( path: '/login', - builder: (BuildContext context, GoRouterState state) => - const LoginScreen(), + builder: + (BuildContext context, GoRouterState state) => + const LoginScreen(), ), GoRoute( path: '/home', - builder: (BuildContext context, GoRouterState state) => - const HomeScreen(), + builder: + (BuildContext context, GoRouterState state) => const HomeScreen(), ), GoRoute( path: '/settings', - builder: (BuildContext context, GoRouterState state) => - const SettingsScreen(), + builder: + (BuildContext context, GoRouterState state) => + const SettingsScreen(), ), // Add routes for demonstration purposes GoRoute( path: '/referral', - builder: (BuildContext context, GoRouterState state) => - const SizedBox(), // Never reached + builder: + (BuildContext context, GoRouterState state) => + const SizedBox(), // Never reached ), GoRoute( path: '/crash-test', - builder: (BuildContext context, GoRouterState state) => - const SizedBox(), // Never reached + builder: + (BuildContext context, GoRouterState state) => + const SizedBox(), // Never reached ), GoRoute( path: '/bad-route', - builder: (BuildContext context, GoRouterState state) => - const SizedBox(), // Never reached + builder: + (BuildContext context, GoRouterState state) => + const SizedBox(), // Never reached ), GoRoute( path: '/error', - builder: (BuildContext context, GoRouterState state) => - const ErrorScreen(), + builder: + (BuildContext context, GoRouterState state) => + const ErrorScreen(), ), ], ); @@ -190,7 +198,9 @@ class App extends StatelessWidget { /// Processes referral code in the background without blocking navigation Future _processReferralCodeInBackground( - BuildContext context, String code) async { + BuildContext context, + String code, + ) async { try { final bool success = await ReferralService.processReferralCode(code); @@ -214,10 +224,7 @@ class App extends StatelessWidget { } ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Error: $error'), - backgroundColor: Colors.red, - ), + SnackBar(content: Text('Error: $error'), backgroundColor: Colors.red), ); } } @@ -243,11 +250,9 @@ class App extends StatelessWidget { return; } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Auth token processed: $token'), - ), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Auth token processed: $token'))); }); } } @@ -282,8 +287,10 @@ class HomeScreen extends StatelessWidget { const SizedBox(height: 16), // Deep link examples - Text('Deep Link Tests', - style: Theme.of(context).textTheme.titleMedium), + Text( + 'Deep Link Tests', + style: Theme.of(context).textTheme.titleMedium, + ), const SizedBox(height: 8), const _DeepLinkButton( label: 'Process Referral', @@ -299,8 +306,10 @@ class HomeScreen extends StatelessWidget { // Exception Testing Section const SizedBox(height: 24), - Text('Exception Handling Tests', - style: Theme.of(context).textTheme.titleMedium), + Text( + 'Exception Handling Tests', + style: Theme.of(context).textTheme.titleMedium, + ), const SizedBox(height: 8), const _DeepLinkButton( label: 'Trigger Exception', @@ -339,10 +348,7 @@ class _DeepLinkButton extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - OutlinedButton( - onPressed: () => context.go(path), - child: Text(label), - ), + OutlinedButton(onPressed: () => context.go(path), child: Text(label)), Text( description, style: Theme.of(context).textTheme.bodySmall, @@ -361,20 +367,20 @@ class LoginScreen extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('Login')), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ElevatedButton.icon( - onPressed: () => context.go('/home'), - icon: const Icon(Icons.home), - label: const Text('Go to Home'), - ), - ], + appBar: AppBar(title: const Text('Login')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton.icon( + onPressed: () => context.go('/home'), + icon: const Icon(Icons.home), + label: const Text('Go to Home'), ), - ), - ); + ], + ), + ), + ); } /// Settings screen implementation @@ -384,23 +390,23 @@ class SettingsScreen extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - 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'), - ), - ], + 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'), + ), + ], + ), + ); } /// Error screen implementation @@ -410,32 +416,25 @@ class ErrorScreen extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Error'), - backgroundColor: Colors.red, - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.error_outline, - color: Colors.red, - size: 60, - ), - const SizedBox(height: 16), - const Text( - 'An error occurred during navigation', - style: TextStyle(fontSize: 18), - ), - const SizedBox(height: 24), - ElevatedButton.icon( - onPressed: () => context.go('/home'), - icon: const Icon(Icons.home), - label: const Text('Return to Home'), - ), - ], + appBar: AppBar(title: const Text('Error'), backgroundColor: Colors.red), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 60), + const SizedBox(height: 16), + const Text( + 'An error occurred during navigation', + style: TextStyle(fontSize: 18), ), - ), - ); + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () => context.go('/home'), + icon: const Icon(Icons.home), + label: const Text('Return to Home'), + ), + ], + ), + ), + ); } diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index f284d2c2971..134f6ea64c6 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -17,10 +17,8 @@ import 'route.dart'; import 'router.dart'; import 'state.dart'; -typedef GoRouterRedirect = FutureOr Function( - BuildContext context, - GoRouterState state, -); +typedef GoRouterRedirect = + FutureOr Function(BuildContext context, GoRouterState state); typedef _NamedPath = ({String path, bool caseSensitive}); diff --git a/packages/go_router/lib/src/on_enter.dart b/packages/go_router/lib/src/on_enter.dart index b22d30e845b..965b1c978ec 100644 --- a/packages/go_router/lib/src/on_enter.dart +++ b/packages/go_router/lib/src/on_enter.dart @@ -43,9 +43,10 @@ final class Block extends OnEnterResult { /// the state being navigated to, and a reference to the [GoRouter] instance. /// It returns a [Future] which should resolve to [Allow] if navigation /// is allowed, or [Block] to block navigation. -typedef OnEnter = Future Function( - BuildContext context, - GoRouterState currentState, - GoRouterState nextState, - GoRouter goRouter, -); +typedef OnEnter = + Future Function( + BuildContext context, + GoRouterState currentState, + GoRouterState nextState, + GoRouter goRouter, + ); diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index 9ecdc8dfe3e..9f9c2084d30 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -21,10 +21,11 @@ import 'match.dart'; /// /// The returned [RouteMatchList] is used as parsed result for the /// [GoRouterDelegate]. -typedef ParserExceptionHandler = RouteMatchList Function( - BuildContext context, - RouteMatchList routeMatchList, -); +typedef ParserExceptionHandler = + RouteMatchList Function( + BuildContext context, + RouteMatchList routeMatchList, + ); /// Converts between incoming URLs and a [RouteMatchList] using [RouteMatcher]. /// @@ -37,13 +38,13 @@ class GoRouteInformationParser extends RouteInformationParser { required String? initialLocation, required GoRouter router, required this.onParserException, - }) : _routeMatchListCodec = RouteMatchListCodec(configuration), - _initialLocation = initialLocation, - _onEnterHandler = _OnEnterHandler( - configuration: configuration, - router: router, - onParserException: onParserException, - ); + }) : _routeMatchListCodec = RouteMatchListCodec(configuration), + _initialLocation = initialLocation, + _onEnterHandler = _OnEnterHandler( + configuration: configuration, + router: router, + onParserException: onParserException, + ); /// The route configuration used for parsing [RouteInformation]s. final RouteConfiguration configuration; @@ -83,10 +84,12 @@ class GoRouteInformationParser extends RouteInformationParser { // 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 RouteMatchList matchList = - _routeMatchListCodec.decode(infoState as Map); - return debugParserFuture = - _redirect(context, matchList).then((RouteMatchList value) { + final RouteMatchList matchList = _routeMatchListCodec.decode( + infoState as Map, + ); + return debugParserFuture = _redirect(context, matchList).then(( + RouteMatchList value, + ) { if (value.isError && onParserException != null) { return onParserException!(context, value); } @@ -145,8 +148,9 @@ class GoRouteInformationParser extends RouteInformationParser { } // Process the deprecated redirect AFTER onEnter has allowed navigation - return debugParserFuture = - _redirect(context, initialMatches).then((RouteMatchList matchList) { + return debugParserFuture = _redirect(context, initialMatches).then(( + RouteMatchList matchList, + ) { if (matchList.isError && onParserException != null) { return onParserException!(context, matchList); } @@ -310,9 +314,9 @@ class _OnEnterHandler { required RouteConfiguration configuration, required GoRouter router, required ParserExceptionHandler? onParserException, - }) : _onParserException = onParserException, - _configuration = configuration, - _router = router; + }) : _onParserException = onParserException, + _configuration = configuration, + _router = router; /// The current route configuration. /// @@ -380,15 +384,17 @@ class _OnEnterHandler { ); // Build the next navigation state. - final GoRouterState nextState = - _buildTopLevelGoRouterState(incomingMatches); + final GoRouterState nextState = _buildTopLevelGoRouterState( + incomingMatches, + ); // Get the current state from the router delegate. final RouteMatchList currentMatchList = _router.routerDelegate.currentConfiguration; - final GoRouterState currentState = currentMatchList.isNotEmpty - ? _buildTopLevelGoRouterState(currentMatchList) - : nextState; + final GoRouterState currentState = + currentMatchList.isNotEmpty + ? _buildTopLevelGoRouterState(currentMatchList) + : nextState; // Execute the onEnter callback in a try-catch to capture synchronous exceptions. Future onEnterResultFuture; @@ -408,9 +414,11 @@ class _OnEnterHandler { _resetRedirectionHistory(); - return SynchronousFuture(_onParserException != null - ? _onParserException(context, errorMatchList) - : errorMatchList); + return SynchronousFuture( + _onParserException != null + ? _onParserException(context, errorMatchList) + : errorMatchList, + ); } // Reset the redirection history after attempting the callback. @@ -423,12 +431,15 @@ class _OnEnterHandler { return onCanEnter(); } else if (result is Block) { // Add logging for blocked navigation - log('onEnter blocked navigation from ${currentState.uri} to ${nextState.uri}'); + log( + 'onEnter blocked navigation from ${currentState.uri} to ${nextState.uri}', + ); return onCanNotEnter(); } else { // This should never happen with a sealed class, but provide a fallback throw GoException( - 'Invalid OnEnterResult type: ${result.runtimeType}'); + 'Invalid OnEnterResult type: ${result.runtimeType}', + ); } }, onError: (Object error, StackTrace stackTrace) { @@ -509,8 +520,9 @@ class _OnEnterHandler { ) { _redirectionHistory.add(redirectedUri); if (_redirectionHistory.length > _configuration.redirectLimit) { - final String formattedHistory = - _formatOnEnterRedirectionHistory(_redirectionHistory); + final String formattedHistory = _formatOnEnterRedirectionHistory( + _redirectionHistory, + ); final RouteMatchList errorMatchList = _errorRouteMatchList( redirectedUri, GoException('Too many onEnter calls detected: $formattedHistory'), @@ -550,4 +562,4 @@ class _OnEnterHandler { pathParameters: const {}, ); } -} \ No newline at end of file +} diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index bfc4dcbeb07..725b2d4a210 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -169,8 +169,9 @@ class GoRouter implements RouterConfig { GoRouterPageBuilder? errorPageBuilder, GoRouterWidgetBuilder? errorBuilder, @Deprecated( - 'Use onEnter for redirection. In the onEnter callback, call a navigation ' - 'method like router.go() and return const Block(). ') + 'Use onEnter for redirection. In the onEnter callback, call a navigation ' + 'method like router.go() and return const Block(). ', + ) GoRouterRedirect? redirect, int redirectLimit = 5, Listenable? refreshListenable, diff --git a/packages/go_router/test/on_enter_test.dart b/packages/go_router/test/on_enter_test.dart index 0e0028ce5a3..dfc5de9668c 100644 --- a/packages/go_router/test/on_enter_test.dart +++ b/packages/go_router/test/on_enter_test.dart @@ -18,135 +18,120 @@ void main() { return Future.delayed(Duration.zero).then((_) => router.dispose()); }); - testWidgets( - 'Should set current/next state correctly', - (WidgetTester tester) async { - GoRouterState? capturedCurrentState; - GoRouterState? capturedNextState; - int onEnterCallCount = 0; + testWidgets('Should set current/next state correctly', ( + WidgetTester tester, + ) async { + GoRouterState? capturedCurrentState; + GoRouterState? capturedNextState; + int onEnterCallCount = 0; - router = GoRouter( - initialLocation: '/', - onEnter: ( - BuildContext context, - GoRouterState current, - GoRouterState next, - GoRouter goRouter, - ) async { - onEnterCallCount++; - capturedCurrentState = current; - capturedNextState = next; - return const Allow(); - }, - routes: [ - GoRoute( - path: '/', - builder: (_, __) => const Placeholder(), - routes: [ - GoRoute( - path: 'allowed', - builder: (_, __) => const Placeholder(), - ), - GoRoute( - path: 'blocked', - builder: (_, __) => const Placeholder(), - ), - ], - ), - ], - ); + router = GoRouter( + initialLocation: '/', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + onEnterCallCount++; + capturedCurrentState = current; + capturedNextState = next; + return const Allow(); + }, + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const Placeholder(), + routes: [ + GoRoute(path: 'allowed', builder: (_, __) => const Placeholder()), + GoRoute(path: 'blocked', builder: (_, __) => const Placeholder()), + ], + ), + ], + ); - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); - expect(onEnterCallCount, equals(1)); - expect( - capturedCurrentState?.uri.path, - capturedNextState?.uri.path, - ); - }, - ); + expect(onEnterCallCount, equals(1)); + expect(capturedCurrentState?.uri.path, capturedNextState?.uri.path); + }); - testWidgets( - 'Should block navigation when onEnter returns false', - (WidgetTester tester) async { - final List navigationAttempts = []; - String currentPath = '/'; + testWidgets('Should block navigation when onEnter returns false', ( + WidgetTester tester, + ) async { + final List navigationAttempts = []; + String currentPath = '/'; - router = GoRouter( - initialLocation: '/', - onEnter: ( - BuildContext context, - GoRouterState current, - GoRouterState next, - GoRouter goRouter, - ) async { - navigationAttempts.add(next.uri.path); - currentPath = current.uri.path; - return next.uri.path.contains('blocked') - ? const Block() - : const Allow(); - }, - routes: [ - GoRoute( - path: '/', - builder: (_, __) => const Placeholder(), - routes: [ - GoRoute( - path: 'blocked', - builder: (_, __) => const Placeholder(), - ), - GoRoute( - path: 'allowed', - builder: (_, __) => const Placeholder(), - ), - ], - ), - ], - ); + router = GoRouter( + initialLocation: '/', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + navigationAttempts.add(next.uri.path); + currentPath = current.uri.path; + return next.uri.path.contains('blocked') + ? const Block() + : const Allow(); + }, + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const Placeholder(), + routes: [ + GoRoute(path: 'blocked', builder: (_, __) => const Placeholder()), + GoRoute(path: 'allowed', builder: (_, __) => const Placeholder()), + ], + ), + ], + ); - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); - final BuildContext context = - tester.element(find.byType(Router)); - final GoRouteInformationParser parser = router.routeInformationParser; - final RouteMatchList beforeBlockedNav = - router.routerDelegate.currentConfiguration; - - // Try blocked route - final RouteMatchList blockedMatch = - await parser.parseRouteInformationWithDependencies( - RouteInformation( - uri: Uri.parse('/blocked'), - state: RouteInformationState(type: NavigatingType.go), - ), - context, - ); - await tester.pumpAndSettle(); + final BuildContext context = tester.element(find.byType(Router)); + final GoRouteInformationParser parser = router.routeInformationParser; + final RouteMatchList beforeBlockedNav = + router.routerDelegate.currentConfiguration; - expect(blockedMatch.uri.toString(), - equals(beforeBlockedNav.uri.toString())); - expect(currentPath, equals('/')); - expect(navigationAttempts, contains('/blocked')); - - // Try allowed route - final RouteMatchList allowedMatch = - await parser.parseRouteInformationWithDependencies( - RouteInformation( - uri: Uri.parse('/allowed'), - state: RouteInformationState(type: NavigatingType.go), - ), - context, - ); - expect(allowedMatch.uri.path, equals('/allowed')); - expect(navigationAttempts, contains('/allowed')); - await tester.pumpAndSettle(); - }, - ); + // Try blocked route + final RouteMatchList blockedMatch = await parser + .parseRouteInformationWithDependencies( + RouteInformation( + uri: Uri.parse('/blocked'), + state: RouteInformationState(type: NavigatingType.go), + ), + context, + ); + await tester.pumpAndSettle(); + + expect( + blockedMatch.uri.toString(), + equals(beforeBlockedNav.uri.toString()), + ); + expect(currentPath, equals('/')); + expect(navigationAttempts, contains('/blocked')); + + // Try allowed route + final RouteMatchList allowedMatch = await parser + .parseRouteInformationWithDependencies( + RouteInformation( + uri: Uri.parse('/allowed'), + state: RouteInformationState(type: NavigatingType.go), + ), + context, + ); + expect(allowedMatch.uri.path, equals('/allowed')); + expect(navigationAttempts, contains('/allowed')); + await tester.pumpAndSettle(); + }); - testWidgets('Should allow navigation when onEnter returns true', - (WidgetTester tester) async { + testWidgets('Should allow navigation when onEnter returns true', ( + WidgetTester tester, + ) async { int onEnterCallCount = 0; router = GoRouter( @@ -165,18 +150,20 @@ void main() { routes: [ GoRoute( path: '/home', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Home'))), + builder: + (_, __) => const Scaffold(body: Center(child: Text('Home'))), routes: [ GoRoute( path: 'allowed', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Allowed'))), + builder: + (_, __) => + const Scaffold(body: Center(child: Text('Allowed'))), ), GoRoute( path: 'block', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Blocked'))), + builder: + (_, __) => + const Scaffold(body: Center(child: Text('Blocked'))), ), ], ), @@ -190,74 +177,87 @@ void main() { final BuildContext context = tester.element(find.byType(Scaffold)); final RouteMatchList matchList = await router.routeInformationParser .parseRouteInformationWithDependencies( - RouteInformation( - uri: Uri.parse('/home/allowed'), - state: RouteInformationState(type: NavigatingType.go), - ), - context, - ); + RouteInformation( + uri: Uri.parse('/home/allowed'), + state: RouteInformationState(type: NavigatingType.go), + ), + context, + ); expect(matchList.uri.path, equals('/home/allowed')); expect(onEnterCallCount, greaterThan(0)); }); testWidgets( - 'Should trigger onException when the redirection limit is exceeded', - (WidgetTester tester) async { - final Completer completer = Completer(); - Object? capturedError; + 'Should trigger onException when the redirection limit is exceeded', + (WidgetTester tester) async { + final Completer completer = Completer(); + Object? capturedError; - router = GoRouter( - initialLocation: '/start', - redirectLimit: 2, - onException: - (BuildContext context, GoRouterState state, GoRouter goRouter) { - capturedError = state.error; - goRouter.go('/fallback'); - completer.complete(); - }, - onEnter: (BuildContext context, GoRouterState current, - GoRouterState next, GoRouter goRouter) async { - if (next.uri.path == '/recursive') { - goRouter.push('/recursive'); - return const Block(); - } - return const Allow(); - }, - routes: [ - GoRoute( - path: '/start', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Start'))), - ), - GoRoute( - path: '/recursive', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Recursive'))), - ), - GoRoute( - path: '/fallback', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Fallback'))), - ), - ], - ); + router = GoRouter( + initialLocation: '/start', + redirectLimit: 2, + onException: ( + BuildContext context, + GoRouterState state, + GoRouter goRouter, + ) { + capturedError = state.error; + goRouter.go('/fallback'); + completer.complete(); + }, + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + if (next.uri.path == '/recursive') { + goRouter.push('/recursive'); + return const Block(); + } + return const Allow(); + }, + routes: [ + GoRoute( + path: '/start', + builder: + (_, __) => const Scaffold(body: Center(child: Text('Start'))), + ), + GoRoute( + path: '/recursive', + builder: + (_, __) => + const Scaffold(body: Center(child: Text('Recursive'))), + ), + GoRoute( + path: '/fallback', + builder: + (_, __) => + const Scaffold(body: Center(child: Text('Fallback'))), + ), + ], + ); - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); - router.go('/recursive'); - await completer.future; - await tester.pumpAndSettle(); + router.go('/recursive'); + await completer.future; + await tester.pumpAndSettle(); - expect(capturedError, isNotNull); - expect(capturedError.toString(), - contains('Too many onEnter calls detected')); - expect(find.text('Fallback'), findsOneWidget); - }); + expect(capturedError, isNotNull); + expect( + capturedError.toString(), + contains('Too many onEnter calls detected'), + ); + expect(find.text('Fallback'), findsOneWidget); + }, + ); - testWidgets('Should handle `go` usage in onEnter', - (WidgetTester tester) async { + testWidgets('Should handle `go` usage in onEnter', ( + WidgetTester tester, + ) async { bool isAuthenticatedResult = false; Future isAuthenticated() => @@ -277,8 +277,10 @@ void main() { GoRouter goRouter, ) async { final bool isProtected = next.uri.toString().contains('protected'); - paramsSink.add( - (current: current.uri.toString(), next: next.uri.toString())); + paramsSink.add(( + current: current.uri.toString(), + next: next.uri.toString(), + )); if (!isProtected) { return const Allow(); @@ -292,18 +294,19 @@ void main() { routes: [ GoRoute( path: '/home', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Home'))), + builder: + (_, __) => const Scaffold(body: Center(child: Text('Home'))), ), GoRoute( path: '/protected', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Protected'))), + builder: + (_, __) => + const Scaffold(body: Center(child: Text('Protected'))), ), GoRoute( path: '/sign-in', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Sign-in'))), + builder: + (_, __) => const Scaffold(body: Center(child: Text('Sign-in'))), ), ], ); @@ -317,7 +320,7 @@ void main() { paramsStream, emitsInOrder(<({String current, String next})>[ (current: '/home', next: '/protected'), - (current: '/home', next: '/sign-in') + (current: '/home', next: '/sign-in'), ]), ); router.go('/protected'); @@ -325,10 +328,7 @@ void main() { expect(router.state.uri.toString(), equals('/sign-in')); isAuthenticatedResult = true; - expect( - paramsStream, - emits((current: '/sign-in', next: '/protected')), - ); + expect(paramsStream, emits((current: '/sign-in', next: '/protected'))); router.go('/protected'); await tester.pumpAndSettle(); @@ -336,8 +336,9 @@ void main() { await paramsSink.close(); }); - testWidgets('Should handle `goNamed` usage in onEnter', - (WidgetTester tester) async { + testWidgets('Should handle `goNamed` usage in onEnter', ( + WidgetTester tester, + ) async { final List navigationAttempts = []; router = GoRouter( @@ -351,8 +352,10 @@ void main() { navigationAttempts.add(next.uri.path); if (next.uri.path == '/requires-auth') { - goRouter.goNamed('login-page', - queryParameters: {'from': next.uri.toString()}); + goRouter.goNamed( + 'login-page', + queryParameters: {'from': next.uri.toString()}, + ); return const Block(); } return const Allow(); @@ -360,34 +363,36 @@ void main() { routes: [ GoRoute( path: '/home', - builder: (_, __) => const Scaffold( - body: Center(child: Text('Home')), - ), + builder: + (_, __) => const Scaffold(body: Center(child: Text('Home'))), ), GoRoute( path: '/requires-auth', - builder: (_, __) => const Scaffold( - body: Center(child: Text('Authenticated Content')), - ), + builder: + (_, __) => const Scaffold( + body: Center(child: Text('Authenticated Content')), + ), ), GoRoute( path: '/login', name: 'login-page', - builder: (_, GoRouterState state) => Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Login Page - From: ${state.uri.queryParameters['from'] ?? 'unknown'}'), - ElevatedButton( - onPressed: () => router.go('/home'), - child: const Text('Go Home'), + builder: + (_, GoRouterState state) => Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Login Page - From: ${state.uri.queryParameters['from'] ?? 'unknown'}', + ), + ElevatedButton( + onPressed: () => router.go('/home'), + child: const Text('Go Home'), + ), + ], ), - ], + ), ), - ), - ), ), ], ); @@ -403,8 +408,9 @@ void main() { expect(find.text('Login Page - From: /requires-auth'), findsOneWidget); }); - testWidgets('Should handle `push` usage in onEnter', - (WidgetTester tester) async { + testWidgets('Should handle `push` usage in onEnter', ( + WidgetTester tester, + ) async { const bool isAuthenticatedResult = false; Future isAuthenticated() => @@ -424,8 +430,10 @@ void main() { GoRouter goRouter, ) async { final bool isProtected = next.uri.toString().contains('protected'); - paramsSink.add( - (current: current.uri.toString(), next: next.uri.toString())); + paramsSink.add(( + current: current.uri.toString(), + next: next.uri.toString(), + )); if (!isProtected) { return const Allow(); } @@ -443,22 +451,22 @@ void main() { routes: [ GoRoute( path: '/home', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Home'))), + builder: + (_, __) => const Scaffold(body: Center(child: Text('Home'))), ), GoRoute( path: '/protected', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Protected'))), + builder: + (_, __) => + const Scaffold(body: Center(child: Text('Protected'))), ), GoRoute( path: '/sign-in', - builder: (_, __) => Scaffold( - appBar: AppBar( - title: const Text('Sign in'), - ), - body: const Center(child: Text('Sign-in')), - ), + builder: + (_, __) => Scaffold( + appBar: AppBar(title: const Text('Sign in')), + body: const Center(child: Text('Sign-in')), + ), ), ], ); @@ -472,7 +480,7 @@ void main() { paramsStream, emitsInOrder(<({String current, String next})>[ (current: '/home', next: '/protected'), - (current: '/home', next: '/sign-in') + (current: '/home', next: '/sign-in'), ]), ); await tester.pumpAndSettle(); @@ -487,8 +495,9 @@ void main() { await paramsSink.close(); }); - testWidgets('Should handle `replace` usage in onEnter', - (WidgetTester tester) async { + testWidgets('Should handle `replace` usage in onEnter', ( + WidgetTester tester, + ) async { final List navigationHistory = []; router = GoRouter( @@ -511,21 +520,20 @@ void main() { routes: [ GoRoute( path: '/home', - builder: (_, __) => const Scaffold( - body: Center(child: Text('Home')), - ), + builder: + (_, __) => const Scaffold(body: Center(child: Text('Home'))), ), GoRoute( path: '/old-page', - builder: (_, __) => const Scaffold( - body: Center(child: Text('Old Page')), - ), + builder: + (_, __) => + const Scaffold(body: Center(child: Text('Old Page'))), ), GoRoute( path: '/new-version', - builder: (_, __) => const Scaffold( - body: Center(child: Text('New Version')), - ), + builder: + (_, __) => + const Scaffold(body: Center(child: Text('New Version'))), ), ], ); @@ -548,8 +556,9 @@ void main() { expect(find.text('Home'), findsOneWidget); }); - testWidgets('Should handle `pushReplacement` usage in onEnter', - (WidgetTester tester) async { + testWidgets('Should handle `pushReplacement` usage in onEnter', ( + WidgetTester tester, + ) async { final List navigationLog = []; router = GoRouter( @@ -572,33 +581,33 @@ void main() { routes: [ GoRoute( path: '/home', - builder: (_, __) => const Scaffold( - body: Center(child: Text('Home')), - ), + builder: + (_, __) => const Scaffold(body: Center(child: Text('Home'))), ), GoRoute( path: '/outdated', - builder: (_, __) => const Scaffold( - body: Center(child: Text('Outdated')), - ), + builder: + (_, __) => + const Scaffold(body: Center(child: Text('Outdated'))), ), GoRoute( path: '/updated', - builder: (_, __) => Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Updated'), - ElevatedButton( - onPressed: () => - router.go('/home'), // Use go instead of pop - child: const Text('Go Home'), + builder: + (_, __) => Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Updated'), + ElevatedButton( + onPressed: + () => router.go('/home'), // Use go instead of pop + child: const Text('Go Home'), + ), + ], ), - ], + ), ), - ), - ), ), ], ); @@ -624,172 +633,187 @@ void main() { }); testWidgets( - 'onEnter should handle protected route redirection with query parameters', - (WidgetTester tester) async { - // Test setup - bool isAuthenticatedResult = false; - Future isAuthenticated() => - Future.value(isAuthenticatedResult); - - // Stream to capture onEnter calls - final StreamController<({String current, String next})> paramsSink = - StreamController<({String current, String next})>(); - // Use broadcast stream for potentially multiple listeners/expects if needed, - // although expectLater handles one listener well. - final Stream<({String current, String next})> paramsStream = - paramsSink.stream.asBroadcastStream(); - - // Helper to navigate after sign-in button press - void goToRedirect(GoRouter router, GoRouterState state) { - final String? redirect = state.uri.queryParameters['redirectTo']; - // Use null check and Uri.tryParse for safety - if (redirect != null && Uri.tryParse(redirect) != null) { - // Decode potentially encoded URI component - router.go(Uri.decodeComponent(redirect)); - } else { - // Fallback if redirectTo is missing or invalid - router.go('/home'); + 'onEnter should handle protected route redirection with query parameters', + (WidgetTester tester) async { + // Test setup + bool isAuthenticatedResult = false; + Future isAuthenticated() => + Future.value(isAuthenticatedResult); + + // Stream to capture onEnter calls + final StreamController<({String current, String next})> paramsSink = + StreamController<({String current, String next})>(); + // Use broadcast stream for potentially multiple listeners/expects if needed, + // although expectLater handles one listener well. + final Stream<({String current, String next})> paramsStream = + paramsSink.stream.asBroadcastStream(); + + // Helper to navigate after sign-in button press + void goToRedirect(GoRouter router, GoRouterState state) { + final String? redirect = state.uri.queryParameters['redirectTo']; + // Use null check and Uri.tryParse for safety + if (redirect != null && Uri.tryParse(redirect) != null) { + // Decode potentially encoded URI component + router.go(Uri.decodeComponent(redirect)); + } else { + // Fallback if redirectTo is missing or invalid + router.go('/home'); + } } - } - router = GoRouter( - initialLocation: '/home', - onEnter: ( - BuildContext context, - GoRouterState current, - GoRouterState next, - GoRouter goRouter, - // Renamed parameter to avoid shadowing router variable - ) async { - // Log the navigation attempt state URIs - paramsSink.add( - (current: current.uri.toString(), next: next.uri.toString())); + router = GoRouter( + initialLocation: '/home', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + // Renamed parameter to avoid shadowing router variable + ) async { + // Log the navigation attempt state URIs + paramsSink.add(( + current: current.uri.toString(), + next: next.uri.toString(), + )); - final bool isNavigatingToProtected = next.uri.path == '/protected'; + final bool isNavigatingToProtected = next.uri.path == '/protected'; - // Allow navigation if not going to the protected route - if (!isNavigatingToProtected) { - return const Allow(); - } + // Allow navigation if not going to the protected route + if (!isNavigatingToProtected) { + return const Allow(); + } - // Allow navigation if authenticated - if (await isAuthenticated()) { - return const Allow(); - } + // Allow navigation if authenticated + if (await isAuthenticated()) { + return const Allow(); + } - // If unauthenticated and going to protected route: - // 1. Redirect to sign-in using pushNamed, passing the intended destination - await goRouter.pushNamed( + // If unauthenticated and going to protected route: + // 1. Redirect to sign-in using pushNamed, passing the intended destination + await goRouter.pushNamed( 'sign-in', // Return type likely void or not needed queryParameters: { - 'redirectTo': next.uri.toString() // Pass the full next URI - }); - // 2. Block the original navigation to '/protected' - return const Block(); - }, - routes: [ - GoRoute( - path: '/home', - name: 'home', // Good practice to name routes - builder: (_, __) => const Scaffold( - body: Center(child: Text('Home Screen'))), // Unique text - ), - GoRoute( - path: '/protected', - name: 'protected', // Good practice to name routes - builder: (_, __) => const Scaffold( - body: Center(child: Text('Protected Screen'))), // Unique text - ), - GoRoute( - path: '/sign-in', - name: 'sign-in', - builder: (_, GoRouterState state) => Scaffold( - appBar: AppBar( - title: const Text('Sign In Screen Title'), // Unique text - ), - body: Center( - child: ElevatedButton( - child: const Text('Sign In Button'), // Unique text - onPressed: () => goToRedirect(router, state)), - ), + 'redirectTo': next.uri.toString(), // Pass the full next URI + }, + ); + // 2. Block the original navigation to '/protected' + return const Block(); + }, + routes: [ + GoRoute( + path: '/home', + name: 'home', // Good practice to name routes + builder: + (_, __) => const Scaffold( + body: Center(child: Text('Home Screen')), + ), // Unique text ), - ), - ], - ); + GoRoute( + path: '/protected', + name: 'protected', // Good practice to name routes + builder: + (_, __) => const Scaffold( + body: Center(child: Text('Protected Screen')), + ), // Unique text + ), + GoRoute( + path: '/sign-in', + name: 'sign-in', + builder: + (_, GoRouterState state) => Scaffold( + appBar: AppBar( + title: const Text('Sign In Screen Title'), // Unique text + ), + body: Center( + child: ElevatedButton( + child: const Text('Sign In Button'), // Unique text + onPressed: () => goToRedirect(router, state), + ), + ), + ), + ), + ], + ); - // Expect the stream of onEnter calls to emit events in this specific order - // We use unawaited because expectLater returns a Future that completes - // when the expectation is met or fails, but we want the test execution - // (pumping widgets, triggering actions) to proceed concurrently. - unawaited( - expectLater( - paramsStream, - emitsInOrder([ - // 1. Initial Load to '/home' - equals((current: '/home', next: '/home')), - // 2. Attempt go('/protected') -> onEnter blocks - equals((current: '/home', next: '/protected')), - // 3. onEnter runs for the push('/sign-in?redirectTo=...') triggered internally - equals( - (current: '/home', next: '/sign-in?redirectTo=%2Fprotected')), - // 4. Tap button -> go('/protected') -> onEnter allows access - equals(( - current: - // State when button is tapped - '/sign-in?redirectTo=%2Fprotected', - // Target of the 'go' call - next: '/protected' - )), - ]), - ), - ); + // Expect the stream of onEnter calls to emit events in this specific order + // We use unawaited because expectLater returns a Future that completes + // when the expectation is met or fails, but we want the test execution + // (pumping widgets, triggering actions) to proceed concurrently. + unawaited( + expectLater( + paramsStream, + emitsInOrder([ + // 1. Initial Load to '/home' + equals((current: '/home', next: '/home')), + // 2. Attempt go('/protected') -> onEnter blocks + equals((current: '/home', next: '/protected')), + // 3. onEnter runs for the push('/sign-in?redirectTo=...') triggered internally + equals(( + current: '/home', + next: '/sign-in?redirectTo=%2Fprotected', + )), + // 4. Tap button -> go('/protected') -> onEnter allows access + equals(( + current: + // State when button is tapped + '/sign-in?redirectTo=%2Fprotected', + // Target of the 'go' call + next: '/protected', + )), + ]), + ), + ); - // Initial widget pump - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - // Let initial navigation and builds complete - await tester.pumpAndSettle(); - // Verify initial screen - expect(find.text('Home Screen'), findsOneWidget); + // Initial widget pump + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + // Let initial navigation and builds complete + await tester.pumpAndSettle(); + // Verify initial screen + expect(find.text('Home Screen'), findsOneWidget); - // Trigger navigation to protected route (user is not authenticated) - router.go('/protected'); - // Allow navigation/redirection to complete - await tester.pumpAndSettle(); + // Trigger navigation to protected route (user is not authenticated) + router.go('/protected'); + // Allow navigation/redirection to complete + await tester.pumpAndSettle(); - // Verify state after redirection to sign-in - expect( - router.state.uri.toString(), - equals('/sign-in?redirectTo=%2Fprotected'), - ); - // Verify app bar title - expect(find.text('Sign In Screen Title'), findsOneWidget); - // Verify button exists - expect(find.widgetWithText(ElevatedButton, 'Sign In Button'), - findsOneWidget); - // BackButton appears because sign-in was pushed onto the stack - expect(find.byType(BackButton), findsOneWidget); + // Verify state after redirection to sign-in + expect( + router.state.uri.toString(), + equals('/sign-in?redirectTo=%2Fprotected'), + ); + // Verify app bar title + expect(find.text('Sign In Screen Title'), findsOneWidget); + // Verify button exists + expect( + find.widgetWithText(ElevatedButton, 'Sign In Button'), + findsOneWidget, + ); + // BackButton appears because sign-in was pushed onto the stack + expect(find.byType(BackButton), findsOneWidget); - // Simulate successful authentication - isAuthenticatedResult = true; + // Simulate successful authentication + isAuthenticatedResult = true; - // Trigger navigation back to protected route by tapping the sign-in button - await tester.tap(find.widgetWithText(ElevatedButton, 'Sign In Button')); - // Allow navigation to protected route to complete - await tester.pumpAndSettle(); + // Trigger navigation back to protected route by tapping the sign-in button + await tester.tap(find.widgetWithText(ElevatedButton, 'Sign In Button')); + // Allow navigation to protected route to complete + await tester.pumpAndSettle(); - // Verify final state - expect(router.state.uri.toString(), equals('/protected')); - // Verify final screen - expect(find.text('Protected Screen'), findsOneWidget); - // Verify sign-in screen is gone - expect(find.text('Sign In Screen Title'), findsNothing); + // Verify final state + expect(router.state.uri.toString(), equals('/protected')); + // Verify final screen + expect(find.text('Protected Screen'), findsOneWidget); + // Verify sign-in screen is gone + expect(find.text('Sign In Screen Title'), findsNothing); - // Close the stream controller - await paramsSink.close(); - }); + // Close the stream controller + await paramsSink.close(); + }, + ); - testWidgets('Should handle sequential navigation steps in onEnter', - (WidgetTester tester) async { + testWidgets('Should handle sequential navigation steps in onEnter', ( + WidgetTester tester, + ) async { final List navigationChain = []; final Completer navigationComplete = Completer(); @@ -824,32 +848,32 @@ void main() { routes: [ GoRoute( path: '/start', - builder: (_, __) => const Scaffold( - body: Center(child: Text('Start')), - ), + builder: + (_, __) => const Scaffold(body: Center(child: Text('Start'))), ), GoRoute( path: '/multi-step', - builder: (_, __) => const Scaffold( - body: Center(child: Text('Multi Step')), - ), + builder: + (_, __) => + const Scaffold(body: Center(child: Text('Multi Step'))), ), GoRoute( path: '/step-one', - builder: (_, __) => Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Step One'), - ElevatedButton( - onPressed: () => router.go('/start'), - child: const Text('Go Back to Start'), + builder: + (_, __) => Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Step One'), + ElevatedButton( + onPressed: () => router.go('/start'), + child: const Text('Go Back to Start'), + ), + ], ), - ], + ), ), - ), - ), ), ], ); @@ -881,9 +905,9 @@ void main() { expect(find.text('Start'), findsOneWidget); }); - testWidgets( - 'Should call onException when exceptions thrown in onEnter callback', - (WidgetTester tester) async { + testWidgets('Should call onException when exceptions thrown in onEnter callback', ( + WidgetTester tester, + ) async { final Completer completer = Completer(); Object? capturedError; @@ -891,15 +915,22 @@ void main() { // to avoid triggering the exception when navigating to the fallback route. router = GoRouter( initialLocation: '/error', - onException: - (BuildContext context, GoRouterState state, GoRouter goRouter) { + onException: ( + BuildContext context, + GoRouterState state, + GoRouter goRouter, + ) { capturedError = state.error; // Navigate to a safe fallback route. goRouter.go('/fallback'); completer.complete(); }, - onEnter: (BuildContext context, GoRouterState current, - GoRouterState next, GoRouter goRouter) async { + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { // If the navigation target is '/fallback', allow it without throwing. if (next.uri.path == '/fallback') { return const Allow(); @@ -910,13 +941,15 @@ void main() { routes: [ GoRoute( path: '/error', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Error Page'))), + builder: + (_, __) => + const Scaffold(body: Center(child: Text('Error Page'))), ), GoRoute( path: '/fallback', - builder: (_, __) => - const Scaffold(body: Center(child: Text('Fallback Page'))), + builder: + (_, __) => + const Scaffold(body: Center(child: Text('Fallback Page'))), ), ], ); @@ -937,8 +970,9 @@ void main() { expect(find.text('Fallback Page'), findsOneWidget); }); - testWidgets('onEnter has priority over deprecated redirect', - (WidgetTester tester) async { + testWidgets('onEnter has priority over deprecated redirect', ( + WidgetTester tester, + ) async { int redirectCallCount = 0; int onEnterCallCount = 0; bool lastOnEnterBlocked = false; @@ -946,18 +980,9 @@ void main() { router = GoRouter( initialLocation: '/start', routes: [ - GoRoute( - path: '/start', - builder: (_, __) => const Text('Start'), - ), - GoRoute( - path: '/blocked', - builder: (_, __) => const Text('Blocked'), - ), - GoRoute( - path: '/allowed', - builder: (_, __) => const Text('Allowed'), - ), + GoRoute(path: '/start', builder: (_, __) => const Text('Start')), + GoRoute(path: '/blocked', builder: (_, __) => const Text('Blocked')), + GoRoute(path: '/allowed', builder: (_, __) => const Text('Allowed')), ], onEnter: (_, __, GoRouterState next, ___) async { onEnterCallCount++; @@ -988,9 +1013,9 @@ void main() { expect(onEnterCallCount, greaterThan(initialOnEnterCount)); expect( - redirectCallCount, - equals( - initialRedirectCount)); // redirect should not be called for blocked routes + redirectCallCount, + equals(initialRedirectCount), + ); // redirect should not be called for blocked routes expect(find.text('Start'), findsOneWidget); // Should stay on start expect(lastOnEnterBlocked, isTrue); @@ -1001,28 +1026,23 @@ void main() { expect(onEnterCallCount, greaterThan(initialOnEnterCount + 1)); expect( - redirectCallCount, - greaterThan( - beforeAllowedRedirectCount)); // redirect should be called this time + redirectCallCount, + greaterThan(beforeAllowedRedirectCount), + ); // redirect should be called this time expect(find.text('Allowed'), findsOneWidget); }); - testWidgets('onEnter blocks navigation and preserves current route', - (WidgetTester tester) async { + testWidgets('onEnter blocks navigation and preserves current route', ( + WidgetTester tester, + ) async { String? capturedCurrentPath; String? capturedNextPath; router = GoRouter( initialLocation: '/page1', routes: [ - GoRoute( - path: '/page1', - builder: (_, __) => const Text('Page 1'), - ), - GoRoute( - path: '/page2', - builder: (_, __) => const Text('Page 2'), - ), + GoRoute(path: '/page1', builder: (_, __) => const Text('Page 1')), + GoRoute(path: '/page2', builder: (_, __) => const Text('Page 2')), GoRoute( path: '/protected', builder: (_, __) => const Text('Protected'), From 10404f8c4771898333e53842fd742fa4e6beff66 Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Tue, 19 Aug 2025 21:02:48 +0300 Subject: [PATCH 18/27] [go_router] returned back missing docs in GoRouterRedirect --- packages/go_router/lib/src/configuration.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index 134f6ea64c6..f52c939ae51 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -17,6 +17,7 @@ import 'route.dart'; import 'router.dart'; import 'state.dart'; +/// The signature of the redirect callback. typedef GoRouterRedirect = FutureOr Function(BuildContext context, GoRouterState state); From 2b3d0be146423d83cc1f6138cf4a4e018b88d96f Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Tue, 19 Aug 2025 21:25:14 +0300 Subject: [PATCH 19/27] [go_router] Fix license headers to match repository standards - Updated copyright notice to include "All rights reserved" - Fixed formatting to match other files in the repository - Resolved merge conflicts from upstream changes --- packages/go_router/example/lib/top_level_on_enter.dart | 6 +++--- packages/go_router/lib/src/parser.dart | 5 ++--- 2 files changed, 5 insertions(+), 6 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 5cbd43d6355..c54193e72c5 100644 --- a/packages/go_router/example/lib/top_level_on_enter.dart +++ b/packages/go_router/example/lib/top_level_on_enter.dart @@ -1,6 +1,6 @@ -// 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. +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index 9f9c2084d30..7583acde223 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -1,9 +1,8 @@ -// go_route_information_parser.dart -// ignore_for_file: use_build_context_synchronously -// Copyright 2013 The Flutter Authors. +// 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. +// ignore_for_file: use_build_context_synchronously import 'dart:async'; import 'dart:math'; From b554e6c6c22377f53ce251c93084060a99cbe9e2 Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Thu, 21 Aug 2025 00:44:21 +0300 Subject: [PATCH 20/27] [go_router] sealed `onEnter` (Allow/Block.then) + compose legacy redirect * New `onEnter` returns `OnEnterResult` (`Allow`/`Block` with optional `then`) * Legacy top-level `redirect` composed into `onEnter`; parser path simplified * Normalize URIs; skip `onEnter` on restore; clear vs keep history (`Block()` vs `Block(then)`) * Refresh example; update/add tests --- .../example/lib/top_level_on_enter.dart | 333 ++++++++++-------- packages/go_router/lib/go_router.dart | 5 +- packages/go_router/lib/src/configuration.dart | 13 +- packages/go_router/lib/src/on_enter.dart | 54 +-- packages/go_router/lib/src/parser.dart | 100 +++++- packages/go_router/lib/src/router.dart | 49 ++- packages/go_router/test/on_enter_test.dart | 322 ++++++++++++++++- 7 files changed, 640 insertions(+), 236 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 c54193e72c5..3be2c4571b2 100644 --- a/packages/go_router/example/lib/top_level_on_enter.dart +++ b/packages/go_router/example/lib/top_level_on_enter.dart @@ -1,6 +1,6 @@ -// 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. +// Copyright 2013 The Flutter Authors. +// Use of this source code is governed by a BSD-style license. +// ignore_for_file: use_build_context_synchronously import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -36,7 +36,6 @@ class App extends StatelessWidget { return MaterialApp.router( routerConfig: _router(key), title: 'Top-level onEnter', - theme: ThemeData(useMaterial3: true, primarySwatch: Colors.blue), ); } @@ -47,7 +46,8 @@ class App extends StatelessWidget { initialLocation: '/home', debugLogDiagnostics: true, - /// Exception handler to gracefully handle errors in navigation + // If anything goes sideways during parsing/guards/redirects, + // surface a friendly message and offer a one-tap “Go Home”. onException: ( BuildContext context, GoRouterState state, @@ -76,134 +76,137 @@ class App extends StatelessWidget { } }, - /// Handles incoming routes before navigation occurs. - /// This callback can: - /// 1. Block navigation and perform actions (return Block()) - /// 2. Allow navigation to proceed (return Allow()) - /// 3. Show loading states during async operations - /// 4. Demonstrate exception handling + /// Top-level guard runs BEFORE legacy top-level redirects and route-level redirects. + /// Return: + /// - `Allow()` to proceed (optionally with `then:` side-effects) + /// - `Block()` to cancel navigation (optionally with `then:` to defer an action/redirect) onEnter: ( BuildContext context, - GoRouterState currentState, - GoRouterState nextState, - GoRouter goRouter, + GoRouterState current, + GoRouterState next, + GoRouter router, ) async { - // Track analytics for deep links - if (nextState.uri.hasQuery || nextState.uri.hasFragment) { - _handleDeepLinkTracking(nextState.uri); + // Example: fire-and-forget analytics for deep links; never block the nav + if (next.uri.hasQuery || next.uri.hasFragment) { + await ReferralService.trackDeepLink(next.uri).catchError(( + Object e, + __, + ) { + debugPrint('Failed to track deep link: $e'); + }); } - // Handle special routes - switch (nextState.uri.path) { + switch (next.uri.path) { + // Block deep-link routes that should never render a page + // (we stay on the current page and show a lightweight UI instead). case '/referral': - final String? code = nextState.uri.queryParameters['code']; - if (code != null) { - // Use SnackBar for feedback instead of dialog - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Processing referral code...'), - duration: Duration(seconds: 2), - ), - ); + { + final String? code = next.uri.queryParameters['code']; + if (code != null) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Processing referral code...'), + duration: Duration(seconds: 2), + ), + ); + } + // Do the real work in the background; don’t keep the user waiting. + await _processReferralCodeInBackground(context, code); } - - // Process code in background - don't block with complex UI - await _processReferralCodeInBackground(context, code); + return const Block(); // keep user where they are } - return const Block(); // Prevent navigation + // Simulate an OAuth callback: do background work + toast; never show a page at /auth case '/auth': - if (nextState.uri.queryParameters['token'] != null) { - _handleAuthToken( - context, - nextState.uri.queryParameters['token']!, - ); - return const Block(); // Prevent navigation + { + final String? token = next.uri.queryParameters['token']; + if (token != null) { + _handleAuthToken(context, token); + return const Block(); // cancel showing any /auth page + } + return const Allow(); } - return const OnEnterResult.allow(); + // Demonstrate error reporting path case '/crash-test': - // Deliberately throw an exception to demonstrate error handling throw Exception('Simulated error in onEnter callback!'); - case '/bad-route': - // Runtime type error to test different error types - // ignore: unnecessary_cast - nextState.uri as int; - return const OnEnterResult.allow(); + case '/protected': + { + // ignore: prefer_final_locals + bool isLoggedIn = false; // pretend we’re not authenticated + if (!isLoggedIn) { + // Chaining block: cancel the original nav, then redirect to /login. + // This preserves redirection history to detect loops. + final String from = Uri.encodeComponent(next.uri.toString()); + return Block(then: () => router.go('/login?from=$from')); + } + // ignore: dead_code + return const Allow(); + } default: - // Allow navigation for all other routes - return const OnEnterResult.allow(); + return const Allow(); } }, + routes: [ + // Simple “root → home” GoRoute( path: '/', - redirect: (BuildContext context, GoRouterState state) => '/home', - ), - 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 routes for demonstration purposes - GoRoute( - path: '/referral', - builder: - (BuildContext context, GoRouterState state) => - const SizedBox(), // Never reached + redirect: (BuildContext _, GoRouterState __) => '/home', ), + + // Auth + simple pages + GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), + GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), + GoRoute(path: '/settings', builder: (_, __) => const SettingsScreen()), + + // The following routes will never render (we always Block in onEnter), + // but they exist so deep-links resolve safely. + GoRoute(path: '/referral', builder: (_, __) => const SizedBox.shrink()), + GoRoute(path: '/auth', builder: (_, __) => const SizedBox.shrink()), GoRoute( path: '/crash-test', - builder: - (BuildContext context, GoRouterState state) => - const SizedBox(), // Never reached + builder: (_, __) => const SizedBox.shrink(), ), + + // Route-level redirect happens AFTER top-level onEnter allows. GoRoute( - path: '/bad-route', - builder: - (BuildContext context, GoRouterState state) => - const SizedBox(), // Never reached + path: '/old', + builder: (_, __) => const SizedBox.shrink(), + redirect: (_, __) => '/home?from=old', ), + + // A page that shows fragments (#hash) via state.uri.fragment GoRoute( - path: '/error', - builder: - (BuildContext context, GoRouterState state) => - const ErrorScreen(), + path: '/article/:id', + name: 'article', + builder: (_, GoRouterState state) { + return Scaffold( + appBar: AppBar(title: const Text('Article')), + body: Center( + child: Text( + 'id=${state.pathParameters['id']}; fragment=${state.uri.fragment}', + ), + ), + ); + }, ), + + GoRoute(path: '/error', builder: (_, __) => const ErrorScreen()), ], ); } - /// 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 code in the background without blocking navigation Future _processReferralCodeInBackground( BuildContext context, String code, ) async { try { - final bool success = await ReferralService.processReferralCode(code); - + final bool ok = await ReferralService.processReferralCode(code); if (!context.mounted) { return; } @@ -212,19 +215,19 @@ class App extends StatelessWidget { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - success + ok ? 'Referral code $code applied successfully!' : 'Failed to apply referral code', ), ), ); - } catch (error) { + } catch (e) { if (!context.mounted) { return; } ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error: $error'), backgroundColor: Colors.red), + SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red), ); } } @@ -242,8 +245,7 @@ class App extends StatelessWidget { duration: const Duration(seconds: 2), ), ); - - // Process in background + // background processing — keeps UI responsive and avoids re-entrancy Future(() async { await Future.delayed(const Duration(seconds: 1)); if (!context.mounted) { @@ -264,6 +266,15 @@ class HomeScreen extends StatelessWidget { @override Widget build(BuildContext context) { + void goArticleWithFragment() { + context.goNamed( + 'article', + pathParameters: {'id': '42'}, + // demonstrate fragment support (e.g., for in-page anchors) + fragment: 'section-2', + ); + } + return Scaffold( appBar: AppBar( title: const Text('Top-level onEnter'), @@ -274,56 +285,68 @@ class HomeScreen extends StatelessWidget { ), ], ), - 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), + body: ListView( + padding: const EdgeInsets.all(16), + 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', - ), + 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', + ), - // Exception Testing Section - const SizedBox(height: 24), - Text( - 'Exception Handling Tests', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - const _DeepLinkButton( - label: 'Trigger Exception', - path: '/crash-test', - description: 'Throws exception in onEnter callback', - ), - const SizedBox(height: 8), - const _DeepLinkButton( - label: 'Type Error Exception', - path: '/bad-route', - description: 'Triggers a runtime type error', - ), - ], - ), + const SizedBox(height: 24), + Text( + 'Guards & Redirects', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + const _DeepLinkButton( + label: 'Protected Route (redirects to login)', + path: '/protected', + description: 'Top-level onEnter returns Block(then: go(...))', + ), + const SizedBox(height: 8), + const _DeepLinkButton( + label: 'Legacy Route-level Redirect', + path: '/old', + description: 'Route-level redirect to /home?from=old', + ), + + const SizedBox(height: 24), + Text( + 'Fragments (hash)', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: goArticleWithFragment, + child: const Text('Open Article #section-2'), + ), + Text( + "Uses goNamed(..., fragment: 'section-2') and reads state.uri.fragment", + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], ), ); } @@ -343,19 +366,19 @@ class _DeepLinkButton extends StatelessWidget { @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( + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + OutlinedButton(onPressed: () => context.go(path), child: Text(label)), + Padding( + padding: const EdgeInsets.only(top: 4, bottom: 12), + child: Text( description, style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center, ), - ], - ), + ), + ], ); } } diff --git a/packages/go_router/lib/go_router.dart b/packages/go_router/lib/go_router.dart index b8f75f4edde..e369b338f21 100644 --- a/packages/go_router/lib/go_router.dart +++ b/packages/go_router/lib/go_router.dart @@ -15,10 +15,11 @@ export 'src/misc/custom_parameter.dart'; export 'src/misc/errors.dart'; export 'src/misc/extensions.dart'; export 'src/misc/inherited_router.dart'; -export 'src/on_enter.dart' show Allow, Block, OnEnter, OnEnterResult; +export 'src/on_enter.dart' show Allow, Block, OnEnterResult; export 'src/pages/custom_transition_page.dart'; export 'src/parser.dart'; export 'src/route.dart'; export 'src/route_data.dart' hide NoOpPage; -export 'src/router.dart'; +export 'src/router.dart' + show GoExceptionHandler, GoRouter, OnEnter, RoutingConfig; export 'src/state.dart' hide GoRouterStateRegistry, GoRouterStateRegistryScope; diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index f52c939ae51..542b0b5b26e 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -11,10 +11,9 @@ import 'package:flutter/widgets.dart'; import 'logging.dart'; import 'match.dart'; import 'misc/errors.dart'; -import 'on_enter.dart'; import 'path_utils.dart'; import 'route.dart'; -import 'router.dart'; +import 'router.dart' show OnEnter, RoutingConfig; import 'state.dart'; /// The signature of the redirect callback. @@ -90,7 +89,7 @@ class RouteConfiguration { } else if (route is ShellRoute) { _debugCheckParentNavigatorKeys( route.routes, - >[...allowedKeys..add(route.navigatorKey)], + >[...allowedKeys, route.navigatorKey], ); } else if (route is StatefulShellRoute) { for (final StatefulShellBranch branch in route.branches) { @@ -142,16 +141,18 @@ class RouteConfiguration { if (branch.initialLocation == null) { // Recursively search for the first GoRoute descendant. Will // throw assertion error if not found. - final GoRoute? route = branch.defaultRoute; + final GoRoute? defaultGoRoute = branch.defaultRoute; final String? initialLocation = - route != null ? locationForRoute(route) : null; + defaultGoRoute != null + ? locationForRoute(defaultGoRoute) + : null; assert( initialLocation != null, 'The default location of a StatefulShellBranch must be ' 'derivable from GoRoute descendant', ); assert( - route!.pathParameters.isEmpty, + defaultGoRoute!.pathParameters.isEmpty, 'The default location of a StatefulShellBranch cannot be ' 'a parameterized route', ); diff --git a/packages/go_router/lib/src/on_enter.dart b/packages/go_router/lib/src/on_enter.dart index 965b1c978ec..4fd9882b0e0 100644 --- a/packages/go_router/lib/src/on_enter.dart +++ b/packages/go_router/lib/src/on_enter.dart @@ -1,52 +1,52 @@ -// 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. import 'dart:async'; -import 'package:flutter/widgets.dart'; - -import 'router.dart'; -import 'state.dart'; - -/// The result of an [onEnter] callback. +/// The result of an onEnter callback. /// /// This sealed class represents the possible outcomes of navigation interception. +/// Being sealed, it can only be extended within this library, ensuring a controlled +/// set of result types while still allowing construction via factory constructors +/// and the public concrete subtypes [Allow] and [Block]. sealed class OnEnterResult { /// Creates an [OnEnterResult]. - const OnEnterResult(); + const OnEnterResult({this.then}); /// Creates an [Allow] result that allows navigation to proceed. - const factory OnEnterResult.allow() = Allow; + /// + /// The [then] callback is executed after the navigation is allowed. + const factory OnEnterResult.allow({FutureOr Function()? then}) = Allow; /// Creates a [Block] result that blocks navigation from proceeding. - const factory OnEnterResult.block() = Block; + /// + /// The [then] callback is executed after the navigation is blocked. + const factory OnEnterResult.block({FutureOr Function()? then}) = Block; + + /// Executed after the decision is committed. Errors are reported and do not revert navigation. + final FutureOr Function()? then; } /// Allows the navigation to proceed. final class Allow extends OnEnterResult { /// Creates an [Allow] result. - const Allow(); + /// + /// The [then] callback runs **after** the navigation is committed. Errors + /// thrown by this callback are reported via `FlutterError.reportError` and + /// do **not** undo the already-committed navigation. + const Allow({super.then}); } /// Blocks the navigation from proceeding. final class Block extends OnEnterResult { /// Creates a [Block] result. - const Block(); + /// + /// The [then] callback is executed after the navigation is blocked. + /// Commonly used to navigate to a different route (e.g. `router.go('/login')`). + /// + /// **History behavior:** a plain `Block()` (no `then`) is a "hard stop" and + /// resets `onEnter`'s internal redirection history so subsequent attempts are + /// evaluated fresh; `Block(then: ...)` keeps history to detect loops. + const Block({super.then}); } - -/// The signature for the top-level [onEnter] callback. -/// -/// This callback receives the [BuildContext], the current navigation state, -/// the state being navigated to, and a reference to the [GoRouter] instance. -/// It returns a [Future] which should resolve to [Allow] if navigation -/// is allowed, or [Block] to block navigation. -typedef OnEnter = - Future Function( - BuildContext context, - GoRouterState currentState, - GoRouterState nextState, - GoRouter goRouter, - ); diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index 7583acde223..88fef9e6084 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -28,8 +28,9 @@ typedef ParserExceptionHandler = /// Converts between incoming URLs and a [RouteMatchList] using [RouteMatcher]. /// -/// Also performs redirection using [RouteRedirector] and integrates the top-level -/// onEnter logic via [OnEnterHandler]. +/// Also integrates the top-level `onEnter` guard and then performs legacy +/// top-level redirect (if any) followed by route-level redirects. See order of +/// operations in `GoRouter` docs. class GoRouteInformationParser extends RouteInformationParser { /// Creates a [GoRouteInformationParser]. GoRouteInformationParser({ @@ -78,11 +79,28 @@ class GoRouteInformationParser extends RouteInformationParser { } final Object infoState = routeInformation.state!; + + // If we're restoring (pop/back or restoring a branch stack), skip onEnter + // but still run legacy + route-level redirects so state can be updated. + if (infoState is RouteInformationState && + infoState.type == NavigatingType.restore) { + final RouteMatchList restored = infoState.baseRouteMatchList!; + return debugParserFuture = _redirect(context, restored).then(( + RouteMatchList value, + ) { + if (value.isError && onParserException != null) { + return onParserException!(context, value); + } + _lastMatchList = value; + return value; + }); + } + // Process legacy state if necessary. if (infoState 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. + // the state. Run redirects to allow state updates. final RouteMatchList matchList = _routeMatchListCodec.decode( infoState as Map, ); @@ -367,18 +385,29 @@ class _OnEnterHandler { return onCanEnter(); } + // Normalize the incoming URI (match what _navigate() does) + Uri normalizedUri = routeInformation.uri; + if (normalizedUri.hasEmptyPath) { + normalizedUri = normalizedUri.replace(path: '/'); + } else if (normalizedUri.path.length > 1 && + normalizedUri.path.endsWith('/')) { + normalizedUri = normalizedUri.replace( + path: normalizedUri.path.substring(0, normalizedUri.path.length - 1), + ); + } + // Check if the redirection history exceeds the configured limit. final RouteMatchList? redirectionErrorMatchList = - _redirectionErrorMatchList(context, routeInformation.uri, infoState); + _redirectionErrorMatchList(context, normalizedUri, infoState); if (redirectionErrorMatchList != null) { // Return immediately if the redirection limit is exceeded. return SynchronousFuture(redirectionErrorMatchList); } - // Find route matches for the incoming URI. + // Find route matches for the normalized URI. final RouteMatchList incomingMatches = _configuration.findMatch( - routeInformation.uri, + normalizedUri, extra: infoState.extra, ); @@ -398,12 +427,17 @@ class _OnEnterHandler { // Execute the onEnter callback in a try-catch to capture synchronous exceptions. Future onEnterResultFuture; try { - onEnterResultFuture = topOnEnter( + final FutureOr result = topOnEnter( context, currentState, nextState, _router, ); + // Convert FutureOr to Future + onEnterResultFuture = + result is OnEnterResult + ? SynchronousFuture(result) + : result; } catch (error) { final RouteMatchList errorMatchList = _errorRouteMatchList( routeInformation.uri, @@ -420,28 +454,56 @@ class _OnEnterHandler { ); } - // Reset the redirection history after attempting the callback. - _resetRedirectionHistory(); - // Handle asynchronous completion and catch any errors. return onEnterResultFuture.then( - (OnEnterResult result) { + (OnEnterResult result) async { + RouteMatchList matchList; + final FutureOr Function()? callback = result.then; + if (result is Allow) { - return onCanEnter(); - } else if (result is Block) { - // Add logging for blocked navigation + matchList = await onCanEnter(); + _resetRedirectionHistory(); // reset after committed navigation + } else { + // Block: check if this is a hard stop or chaining block log( 'onEnter blocked navigation from ${currentState.uri} to ${nextState.uri}', ); - return onCanNotEnter(); - } else { - // This should never happen with a sealed class, but provide a fallback - throw GoException( - 'Invalid OnEnterResult type: ${result.runtimeType}', + matchList = await onCanNotEnter(); + + // Hard stop (no then callback): reset history so user retries don't trigger limit + final bool hardStop = result is Block && callback == null; + if (hardStop) { + _resetRedirectionHistory(); + } + // For chaining blocks (with then), keep history to detect loops + } + + if (callback != null) { + unawaited( + Future.microtask(callback).catchError(( + Object error, + StackTrace stack, + ) { + // Log error but don't crash - navigation already committed + log('Error in then callback: $error'); + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stack, + library: 'go_router', + context: ErrorDescription('while executing then callback'), + ), + ); + }), ); } + + return matchList; }, onError: (Object error, StackTrace stackTrace) { + // Reset history on error to prevent stale state + _resetRedirectionHistory(); + final RouteMatchList errorMatchList = _errorRouteMatchList( routeInformation.uri, error is GoException ? error : GoException(error.toString()), diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index 725b2d4a210..704c2547eb9 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -25,6 +25,20 @@ import 'state.dart'; typedef GoExceptionHandler = void Function(BuildContext context, GoRouterState state, GoRouter router); +/// The signature for the top-level [onEnter] callback. +/// +/// This callback receives the [BuildContext], the current navigation state, +/// the state being navigated to, and a reference to the [GoRouter] instance. +/// It returns a [FutureOr] which should resolve to [Allow] if navigation +/// is allowed, or [Block] to block navigation. +typedef OnEnter = + FutureOr Function( + BuildContext context, + GoRouterState currentState, + GoRouterState nextState, + GoRouter goRouter, + ); + /// A set of parameters that defines routing in GoRouter. /// /// This is typically used with [GoRouter.routingConfig] to create a go router @@ -84,27 +98,27 @@ class RoutingConfig { /// 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. + /// route and conditionally preventing the navigation. Return [Allow] to proceed + /// with navigation or [Block] to cancel it. Both can optionally include an + /// `then` callback for deferred actions. /// - /// When a deep link opens the app and `onEnter` returns `false`, GoRouter - /// will automatically redirect to the initial route or '/'. + /// When a deep link opens the app and `onEnter` returns [Block], GoRouter + /// will stay on the current route or redirect to the initial route. /// /// Example: /// ```dart /// final GoRouter router = GoRouter( /// routes: [...], - /// onEnter: (BuildContext context, Uri uri) { - /// if (uri.path == '/login' && isUserLoggedIn()) { - /// return false; // Prevent navigation to /login + /// onEnter: (BuildContext context, GoRouterState current, + /// GoRouterState next, GoRouter router) async { + /// if (next.uri.path == '/login' && isUserLoggedIn()) { + /// return const Block(); // Prevent navigation to /login /// } - /// if (uri.path == '/referral') { - /// // Save the referral code and prevent navigation - /// saveReferralCode(uri.queryParameters['code']); - /// return false; + /// if (next.uri.path == '/protected' && !isUserLoggedIn()) { + /// // Block and redirect to login + /// return Block(then: () => router.go('/login?from=${next.uri}')); /// } - /// return true; // Allow navigation + /// return const Allow(); // Allow navigation /// }, /// ); /// ``` @@ -122,7 +136,12 @@ class RoutingConfig { /// /// The [onEnter] callback allows intercepting navigation before routes are /// processed. Return [Allow] to proceed or [Block] to prevent navigation. -/// This runs before the deprecated [redirect] callback. +/// This runs **before** any deprecated top-level [redirect] logic (which is +/// internally composed into `onEnter` for backward compatibility) and +/// **before** any *route-level* redirects. Order of operations: +/// 1) `onEnter` (your guard) +/// 2) legacy top-level `redirect` (if provided; executed via `onEnter` composition) +/// 3) route-level `GoRoute.redirect` /// /// The [redirect] callback allows the app to redirect to a new location. /// Alternatively, you can specify a redirect for an individual route using @@ -426,7 +445,7 @@ class GoRouter implements RouterConfig { Object? extra, String? fragment, }) => - /// Construct location with optional fragment, using null-safe navigation + // Construct location with optional fragment go( namedLocation( name, diff --git a/packages/go_router/test/on_enter_test.dart b/packages/go_router/test/on_enter_test.dart index dfc5de9668c..145927148e1 100644 --- a/packages/go_router/test/on_enter_test.dart +++ b/packages/go_router/test/on_enter_test.dart @@ -213,8 +213,7 @@ void main() { GoRouter goRouter, ) async { if (next.uri.path == '/recursive') { - goRouter.push('/recursive'); - return const Block(); + return Block(then: () => goRouter.push('/recursive')); } return const Allow(); }, @@ -288,8 +287,7 @@ void main() { if (await isAuthenticated()) { return const Allow(); } - router.go('/sign-in'); - return const Block(); + return Block(then: () => router.go('/sign-in')); }, routes: [ GoRoute( @@ -352,11 +350,15 @@ void main() { navigationAttempts.add(next.uri.path); if (next.uri.path == '/requires-auth') { - goRouter.goNamed( - 'login-page', - queryParameters: {'from': next.uri.toString()}, + return Block( + then: + () => goRouter.goNamed( + 'login-page', + queryParameters: { + 'from': next.uri.toString(), + }, + ), ); - return const Block(); } return const Allow(); }, @@ -832,10 +834,8 @@ void main() { if (targetPath == '/multi-step') { // Step 1: Go to a different route navigationChain.add('Step 1: Go to /step-one'); - goRouter.go('/step-one'); - - // We're blocking the original navigation - return const Block(); + // We're blocking the original navigation and deferring the go + return Block(then: () => goRouter.go('/step-one')); } // When we reach step-one, mark test as complete @@ -1080,5 +1080,303 @@ void main() { expect(capturedCurrentPath, equals('/page2')); expect(capturedNextPath, equals('/protected')); }); + + testWidgets('pop/restore does not call onEnter', ( + WidgetTester tester, + ) async { + int onEnterCount = 0; + + router = GoRouter( + initialLocation: '/a', + onEnter: (_, __, ___, ____) async { + onEnterCount++; + return const Allow(); + }, + routes: [ + GoRoute( + path: '/a', + builder: (_, __) => const Scaffold(body: Text('A')), + routes: [ + GoRoute( + path: 'b', + builder: (_, __) => const Scaffold(body: Text('B')), + ), + ], + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + expect(onEnterCount, 1); // initial navigation + + router.go('/a/b'); + await tester.pumpAndSettle(); + expect(onEnterCount, 2); // forward nav is guarded + + // Pop back to /a + router.pop(); + await tester.pumpAndSettle(); + + // onEnter should NOT be called for restore + expect(onEnterCount, 2); // unchanged + expect(find.text('A'), findsOneWidget); + }); + + testWidgets( + 'goNamed supports fragment (hash) and preserves it in state.uri', + (WidgetTester tester) async { + router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: + (_, __) => const Scaffold(body: Center(child: Text('Root'))), + ), + GoRoute( + path: '/article/:id', + name: 'article', + builder: (_, GoRouterState state) { + return Scaffold( + body: Center( + child: Text( + 'article=${state.pathParameters['id']};frag=${state.uri.fragment}', + ), + ), + ); + }, + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + // Navigate with a fragment + router.goNamed( + 'article', + pathParameters: {'id': '42'}, + fragment: 'section-2', + ); + await tester.pumpAndSettle(); + + expect(router.state.uri.path, '/article/42'); + expect(router.state.uri.fragment, 'section-2'); + expect(find.text('article=42;frag=section-2'), findsOneWidget); + }, + ); + + testWidgets('relative "./" navigation resolves against current location', ( + WidgetTester tester, + ) async { + router = GoRouter( + initialLocation: '/parent', + routes: [ + GoRoute( + path: '/parent', + builder: + (_, __) => const Scaffold(body: Center(child: Text('Parent'))), + routes: [ + GoRoute( + path: 'child', + builder: + (_, __) => + const Scaffold(body: Center(child: Text('Child'))), + ), + ], + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + expect(find.text('Parent'), findsOneWidget); + + // Use a relative location. This exercises GoRouteInformationProvider._setValue + // and concatenateUris(). + router.go('./child'); + await tester.pumpAndSettle(); + + expect(router.state.uri.path, '/parent/child'); + expect(find.text('Child'), findsOneWidget); + }); + + testWidgets('route-level redirect still runs after onEnter allows', ( + WidgetTester tester, + ) async { + final List seenNextPaths = []; + + router = GoRouter( + initialLocation: '/', + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter goRouter, + ) async { + seenNextPaths.add(next.uri.path); + return const Allow(); // don't block; let route-level redirect run + }, + routes: [ + GoRoute( + path: '/', + builder: + (_, __) => const Scaffold(body: Center(child: Text('Root'))), + ), + GoRoute( + path: '/old', + builder: (_, __) => const SizedBox.shrink(), + // Route-level redirect: should run AFTER onEnter allows + redirect: (_, __) => '/new', + ), + GoRoute( + path: '/new', + builder: + (_, __) => const Scaffold(body: Center(child: Text('New'))), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + // Trigger navigation that hits the redirecting route + router.go('/old'); + await tester.pumpAndSettle(); + + // onEnter should have seen the original target ('/old') + expect(seenNextPaths, contains('/old')); + + // Final destination should be the redirect target + expect(router.state.uri.path, '/new'); + expect(find.text('New'), findsOneWidget); + }); + + testWidgets( + 'Allow(then) error is reported but does not revert navigation', + (WidgetTester tester) async { + // Capture FlutterError.reportError calls + FlutterErrorDetails? reported; + final void Function(FlutterErrorDetails)? oldHandler = + FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + reported = details; + }; + addTearDown(() => FlutterError.onError = oldHandler); + + router = GoRouter( + initialLocation: '/home', + onEnter: (_, __, GoRouterState next, ___) async { + if (next.uri.path == '/boom') { + // Allow, but run a failing "then" callback + return Allow(then: () => throw StateError('then blew up')); + } + return const Allow(); + }, + routes: [ + GoRoute( + path: '/home', + builder: + (_, __) => const Scaffold(body: Center(child: Text('Home'))), + ), + GoRoute( + path: '/boom', + builder: + (_, __) => const Scaffold(body: Center(child: Text('Boom'))), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + expect(find.text('Home'), findsOneWidget); + + router.go('/boom'); + await tester.pumpAndSettle(); // commits nav + runs deferred microtask + + // Navigation should be committed + expect(router.state.uri.path, equals('/boom')); + expect(find.text('Boom'), findsOneWidget); + + // Error from deferred callback should be reported (but not crash) + expect(reported, isNotNull); + expect(reported!.exception.toString(), contains('then blew up')); + }, + ); + + testWidgets('Hard-stop vs chaining resets onEnter history', ( + WidgetTester tester, + ) async { + // With redirectLimit=1: + // - Block() (no then) should reset history so repeated attempts don't hit the limit. + // - Block(then: go(...)) keeps history and will exceed the limit. + int onExceptionCalls = 0; + final Completer exceededCompleter = Completer(); + + router = GoRouter( + initialLocation: '/start', + redirectLimit: 1, + onException: (_, __, ___) { + onExceptionCalls++; + if (!exceededCompleter.isCompleted) { + exceededCompleter.complete(); + } + }, + onEnter: (_, __, GoRouterState next, GoRouter goRouter) async { + if (next.uri.path == '/blocked-once') { + // Hard stop: no then -> history should reset + return const Block(); + } + if (next.uri.path == '/chain') { + // Chaining block: keep history -> will exceed limit + return Block(then: () => goRouter.go('/chain')); + } + return const Allow(); + }, + routes: [ + GoRoute( + path: '/start', + builder: + (_, __) => const Scaffold(body: Center(child: Text('Start'))), + ), + GoRoute( + path: '/blocked-once', + builder: + (_, __) => + const Scaffold(body: Center(child: Text('BlockedOnce'))), + ), + GoRoute( + path: '/chain', + builder: + (_, __) => const Scaffold(body: Center(child: Text('Chain'))), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + expect(router.state.uri.path, '/start'); + + // 1st attempt: hard-stop; should not trigger onException + router.go('/blocked-once'); + await tester.pumpAndSettle(); + expect(router.state.uri.path, '/start'); + expect(onExceptionCalls, 0); + + // 2nd attempt: history should have been reset; still no onException + router.go('/blocked-once'); + await tester.pumpAndSettle(); + expect(router.state.uri.path, '/start'); + expect(onExceptionCalls, 0); + + // Chaining case: should exceed limit and fire onException once + router.go('/chain'); + await exceededCompleter.future; + await tester.pumpAndSettle(); + expect(onExceptionCalls, 1); + // We're still on '/start' because the guarded nav never committed + expect(router.state.uri.path, '/start'); + }); }); } From 99ab3c3c259122607fe796586847287ee9b10bca Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Thu, 21 Aug 2025 00:46:34 +0300 Subject: [PATCH 21/27] [go_router] Fix license headers to match repository standards --- packages/go_router/example/lib/top_level_on_enter.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 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 3be2c4571b2..f4359d2e7da 100644 --- a/packages/go_router/example/lib/top_level_on_enter.dart +++ b/packages/go_router/example/lib/top_level_on_enter.dart @@ -1,5 +1,7 @@ -// Copyright 2013 The Flutter Authors. -// Use of this source code is governed by a BSD-style license. +// 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. + // ignore_for_file: use_build_context_synchronously import 'package:flutter/material.dart'; From 265f26f9d7aefaddb8893da82c17a38b8a69a76f Mon Sep 17 00:00:00 2001 From: omar-hanafy Date: Fri, 22 Aug 2025 20:02:51 +0300 Subject: [PATCH 22/27] [go_router] Refactor parser and on_enter for clarity and type safety Simplified the OnEnterResult class documentation and removed factory constructors from the sealed class. Introduced type aliases for navigation callbacks and route information state in parser.dart, and updated method signatures to use these aliases for improved readability and type safety. Also streamlined fallback logic when navigation is blocked. --- packages/go_router/lib/src/on_enter.dart | 18 ++++------------ packages/go_router/lib/src/parser.dart | 26 ++++++++++++------------ 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/packages/go_router/lib/src/on_enter.dart b/packages/go_router/lib/src/on_enter.dart index 4fd9882b0e0..7f6beb31dac 100644 --- a/packages/go_router/lib/src/on_enter.dart +++ b/packages/go_router/lib/src/on_enter.dart @@ -7,24 +7,14 @@ import 'dart:async'; /// The result of an onEnter callback. /// /// This sealed class represents the possible outcomes of navigation interception. -/// Being sealed, it can only be extended within this library, ensuring a controlled -/// set of result types while still allowing construction via factory constructors -/// and the public concrete subtypes [Allow] and [Block]. +/// This class can't be extended. One must use one of its subtypes, [Allow] or +/// [Block], to indicate the result. sealed class OnEnterResult { /// Creates an [OnEnterResult]. const OnEnterResult({this.then}); - /// Creates an [Allow] result that allows navigation to proceed. - /// - /// The [then] callback is executed after the navigation is allowed. - const factory OnEnterResult.allow({FutureOr Function()? then}) = Allow; - - /// Creates a [Block] result that blocks navigation from proceeding. - /// - /// The [then] callback is executed after the navigation is blocked. - const factory OnEnterResult.block({FutureOr Function()? then}) = Block; - - /// Executed after the decision is committed. Errors are reported and do not revert navigation. + /// Executed after the decision is committed. + /// Errors are reported and do not revert navigation. final FutureOr Function()? then; } diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index 88fef9e6084..92f366ab375 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -26,6 +26,12 @@ typedef ParserExceptionHandler = RouteMatchList routeMatchList, ); +/// The function signature for navigation callbacks in [_OnEnterHandler]. +typedef NavigationCallback = Future Function(); + +/// Type alias for route information state with dynamic type parameter. +typedef RouteInfoState = RouteInformationState; + /// Converts between incoming URLs and a [RouteMatchList] using [RouteMatcher]. /// /// Also integrates the top-level `onEnter` guard and then performs legacy @@ -121,17 +127,11 @@ class GoRouteInformationParser extends RouteInformationParser { infoState: infoState, onCanEnter: () => _navigate(routeInformation, context, infoState), onCanNotEnter: () { - // If navigation is blocked, return the last successful match or a fallback. + // If navigation is blocked, return the last successful match or empty. if (_lastMatchList != null) { return SynchronousFuture(_lastMatchList!); } else { - final Uri defaultUri = Uri.parse(_initialLocation ?? '/'); - final RouteMatchList fallbackMatches = configuration.findMatch( - defaultUri, - extra: infoState.extra, - ); - _lastMatchList = fallbackMatches; - return SynchronousFuture(fallbackMatches); + return SynchronousFuture(RouteMatchList.empty); } }, ); @@ -145,7 +145,7 @@ class GoRouteInformationParser extends RouteInformationParser { Future _navigate( RouteInformation routeInformation, BuildContext context, - RouteInformationState infoState, + RouteInfoState infoState, ) { // Normalize the URI: ensure it has a valid path and remove trailing slashes. Uri uri = routeInformation.uri; @@ -375,9 +375,9 @@ class _OnEnterHandler { Future handleTopOnEnter({ required BuildContext context, required RouteInformation routeInformation, - required RouteInformationState infoState, - required Future Function() onCanEnter, - required Future Function() onCanNotEnter, + required RouteInfoState infoState, + required NavigationCallback onCanEnter, + required NavigationCallback onCanNotEnter, }) { final OnEnter? topOnEnter = _configuration.topOnEnter; // If no onEnter is configured, allow navigation immediately. @@ -577,7 +577,7 @@ class _OnEnterHandler { RouteMatchList? _redirectionErrorMatchList( BuildContext context, Uri redirectedUri, - RouteInformationState infoState, + RouteInfoState infoState, ) { _redirectionHistory.add(redirectedUri); if (_redirectionHistory.length > _configuration.redirectLimit) { From 01f7ea60203f016e40978f86ece037a142ee6d61 Mon Sep 17 00:00:00 2001 From: omar-hanafy Date: Fri, 22 Aug 2025 20:10:30 +0300 Subject: [PATCH 23/27] [go_router] Remove unused initialLocation parameter from parser. --- packages/go_router/lib/src/parser.dart | 4 ---- packages/go_router/lib/src/router.dart | 1 - 2 files changed, 5 deletions(-) diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index 92f366ab375..50a7097be44 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -41,11 +41,9 @@ class GoRouteInformationParser extends RouteInformationParser { /// Creates a [GoRouteInformationParser]. GoRouteInformationParser({ required this.configuration, - required String? initialLocation, required GoRouter router, required this.onParserException, }) : _routeMatchListCodec = RouteMatchListCodec(configuration), - _initialLocation = initialLocation, _onEnterHandler = _OnEnterHandler( configuration: configuration, router: router, @@ -60,8 +58,6 @@ class GoRouteInformationParser extends RouteInformationParser { final RouteMatchListCodec _routeMatchListCodec; - final String? _initialLocation; - /// Stores the last successful match list to enable "stay" on the same route. RouteMatchList? _lastMatchList; diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index 704c2547eb9..e7cafe5439f 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -299,7 +299,6 @@ class GoRouter implements RouterConfig { routeInformationParser = GoRouteInformationParser( onParserException: parserExceptionHandler, configuration: configuration, - initialLocation: initialLocation, router: this, ); From 22a29bd0b43d0764bc5dfe9b094eace720adf67d Mon Sep 17 00:00:00 2001 From: omar-hanafy Date: Wed, 27 Aug 2025 23:29:09 +0300 Subject: [PATCH 24/27] [go_router] Ensure onEnter runs during state restoration, and unified the onEnter and redirect guard handling. --- packages/go_router/lib/src/configuration.dart | 144 +++++----- .../lib/src/information_provider.dart | 16 +- packages/go_router/lib/src/parser.dart | 261 ++++++++++-------- packages/go_router/lib/src/router.dart | 8 +- packages/go_router/test/on_enter_test.dart | 163 ++++++++++- 5 files changed, 397 insertions(+), 195 deletions(-) diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index 542b0b5b26e..c9ebea6ef81 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -246,7 +246,8 @@ class RouteConfiguration { /// The list of top level routes used by [GoRouterDelegate]. List get routes => _routingConfig.value.routes; - /// Top level page redirect. + /// Top level page redirect (deprecated). + /// This is handled via applyTopLegacyRedirect and runs at most once per navigation. GoRouterRedirect get topRedirect => _routingConfig.value.redirect; /// Top level page on enter. @@ -255,6 +256,16 @@ class RouteConfiguration { /// The limit for the number of consecutive redirects. int get redirectLimit => _routingConfig.value.redirectLimit; + /// Normalizes a URI by ensuring it has a valid path and removing trailing slashes. + static Uri normalizeUri(Uri uri) { + if (uri.hasEmptyPath) { + return uri.replace(path: '/'); + } else if (uri.path.length > 1 && uri.path.endsWith('/')) { + return uri.replace(path: uri.path.substring(0, uri.path.length - 1)); + } + return uri; + } + /// The global key for top level navigator. final GlobalKey navigatorKey; @@ -383,8 +394,9 @@ class RouteConfiguration { return const []; } - /// Processes redirects by returning a new [RouteMatchList] representing the new - /// location. + /// Processes route-level redirects by returning a new [RouteMatchList] representing the new + /// location. This method now handles ONLY route-level redirects. + /// Top-level redirects are handled by applyTopLegacyRedirect. FutureOr redirect( BuildContext context, FutureOr prevMatchListFuture, { @@ -392,13 +404,12 @@ class RouteConfiguration { }) { FutureOr processRedirect(RouteMatchList prevMatchList) { final String prevLocation = prevMatchList.uri.toString(); - FutureOr processTopLevelRedirect( - String? topRedirectLocation, - ) { - if (topRedirectLocation != null && - topRedirectLocation != prevLocation) { + + FutureOr finish(String? routeRedirectLocation) { + if (routeRedirectLocation != null && + routeRedirectLocation != prevLocation) { final RouteMatchList newMatch = _getNewMatches( - topRedirectLocation, + routeRedirectLocation, prevMatchList.uri, redirectHistory, ); @@ -407,59 +418,27 @@ class RouteConfiguration { } return redirect(context, newMatch, redirectHistory: redirectHistory); } - - FutureOr processRouteLevelRedirect( - String? routeRedirectLocation, - ) { - if (routeRedirectLocation != null && - routeRedirectLocation != prevLocation) { - final RouteMatchList newMatch = _getNewMatches( - routeRedirectLocation, - prevMatchList.uri, - redirectHistory, - ); - - if (newMatch.isError) { - return newMatch; - } - return redirect( - context, - newMatch, - redirectHistory: redirectHistory, - ); - } - return prevMatchList; - } - - final List routeMatches = []; - prevMatchList.visitRouteMatches((RouteMatchBase match) { - if (match.route.redirect != null) { - routeMatches.add(match); - } - return true; - }); - final FutureOr routeLevelRedirectResult = - _getRouteLevelRedirect(context, prevMatchList, routeMatches, 0); - - if (routeLevelRedirectResult is String?) { - return processRouteLevelRedirect(routeLevelRedirectResult); - } - return routeLevelRedirectResult.then( - processRouteLevelRedirect, - ); + return prevMatchList; } - redirectHistory.add(prevMatchList); - // Check for top-level redirect - final FutureOr topRedirectResult = _routingConfig.value.redirect( + // Route-level redirects only. + final List routeMatches = []; + prevMatchList.visitRouteMatches((RouteMatchBase match) { + if (match.route.redirect != null) { + routeMatches.add(match); + } + return true; + }); + final FutureOr routeLevel = _getRouteLevelRedirect( context, - buildTopLevelGoRouterState(prevMatchList), + prevMatchList, + routeMatches, + 0, ); - - if (topRedirectResult is String?) { - return processTopLevelRedirect(topRedirectResult); + if (routeLevel is String?) { + return finish(routeLevel); } - return topRedirectResult.then(processTopLevelRedirect); + return routeLevel.then(finish); } if (prevMatchListFuture is RouteMatchList) { @@ -468,6 +447,38 @@ class RouteConfiguration { return prevMatchListFuture.then(processRedirect); } + /// Applies the (deprecated) top-level redirect to [prevMatchList] and returns the + /// resulting matches. Returns [prevMatchList] when no redirect happens. + /// Shares [redirectHistory] with later route-level redirects for proper loop detection. + /// + /// Note: Legacy top-level redirect is executed at most once per navigation, + /// before route-level redirects. It does not re-evaluate if it redirects to + /// a location that would itself trigger another top-level redirect. + FutureOr applyTopLegacyRedirect( + BuildContext context, + RouteMatchList prevMatchList, { + required List redirectHistory, + }) { + final String prevLocation = prevMatchList.uri.toString(); + FutureOr done(String? topLocation) { + if (topLocation != null && topLocation != prevLocation) { + final RouteMatchList newMatch = _getNewMatches( + topLocation, + prevMatchList.uri, + redirectHistory, + ); + return newMatch; + } + return prevMatchList; + } + + final FutureOr res = _routingConfig.value.redirect( + context, + buildTopLevelGoRouterState(prevMatchList), + ); + return res is String? ? done(res) : res.then(done); + } + FutureOr _getRouteLevelRedirect( BuildContext context, RouteMatchList matchList, @@ -503,8 +514,14 @@ class RouteConfiguration { List redirectHistory, ) { try { - final RouteMatchList newMatch = findMatch(Uri.parse(newLocation)); - _addRedirect(redirectHistory, newMatch, previousLocation); + // Normalize the URI to avoid trailing slash inconsistencies + final Uri uri = normalizeUri(Uri.parse(newLocation)); + + final RouteMatchList newMatch = findMatch(uri); + // Only add successful matches to redirect history + if (!newMatch.isError) { + _addRedirect(redirectHistory, newMatch); + } return newMatch; } on GoException catch (e) { log('Redirection exception: ${e.message}'); @@ -515,17 +532,14 @@ class RouteConfiguration { /// Adds the redirect to [redirects] if it is valid. /// /// Throws if a loop is detected or the redirection limit is reached. - void _addRedirect( - List redirects, - RouteMatchList newMatch, - Uri prevLocation, - ) { + void _addRedirect(List redirects, RouteMatchList newMatch) { if (redirects.contains(newMatch)) { throw GoException( 'redirect loop detected ${_formatRedirectionHistory([...redirects, newMatch])}', ); } - if (redirects.length > _routingConfig.value.redirectLimit) { + // Check limit before adding (redirects should only contain actual redirects, not the initial location) + if (redirects.length >= _routingConfig.value.redirectLimit) { throw GoException( 'too many redirects ${_formatRedirectionHistory([...redirects, newMatch])}', ); diff --git a/packages/go_router/lib/src/information_provider.dart b/packages/go_router/lib/src/information_provider.dart index 79009888620..8dd3b6a4b39 100644 --- a/packages/go_router/lib/src/information_provider.dart +++ b/packages/go_router/lib/src/information_provider.dart @@ -72,6 +72,20 @@ class RouteInformationState { /// The type of navigation. final NavigatingType type; + + /// Factory constructor for 'go' navigation type. + static RouteInformationState go({Object? extra}) => + RouteInformationState(extra: extra, type: NavigatingType.go); + + /// Factory constructor for 'restore' navigation type. + static RouteInformationState restore({ + required RouteMatchList base, + Object? extra, + }) => RouteInformationState( + extra: extra ?? base.extra, + baseRouteMatchList: base, + type: NavigatingType.restore, + ); } /// The [RouteInformationProvider] created by go_router. @@ -253,7 +267,7 @@ class GoRouteInformationProvider extends RouteInformationProvider } else { _value = RouteInformation( uri: routeInformation.uri, - state: RouteInformationState(type: NavigatingType.go), + state: RouteInformationState.go(), ); _valueInEngine = _kEmptyRouteInformation; } diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index 50a7097be44..208e01a29bc 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -15,8 +15,7 @@ import 'match.dart'; /// The function signature of [GoRouteInformationParser.onParserException]. /// -/// The `routeMatchList` parameter contains the exception explains the issue -/// occurred. +/// The `routeMatchList` parameter carries the exception describing the issue. /// /// The returned [RouteMatchList] is used as parsed result for the /// [GoRouterDelegate]. @@ -34,9 +33,9 @@ typedef RouteInfoState = RouteInformationState; /// Converts between incoming URLs and a [RouteMatchList] using [RouteMatcher]. /// -/// Also integrates the top-level `onEnter` guard and then performs legacy -/// top-level redirect (if any) followed by route-level redirects. See order of -/// operations in `GoRouter` docs. +/// Integrates the top-level `onEnter` guard. Legacy top-level redirect is +/// adapted and executed inside the parse pipeline after onEnter allows; +/// the parser handles route-level redirects after that. class GoRouteInformationParser extends RouteInformationParser { /// Creates a [GoRouteInformationParser]. GoRouteInformationParser({ @@ -75,60 +74,84 @@ class GoRouteInformationParser extends RouteInformationParser { RouteInformation routeInformation, BuildContext context, ) { - // Safety check: if no state is provided, return an empty match list. - if (routeInformation.state == null) { - return SynchronousFuture(RouteMatchList.empty); - } - - final Object infoState = routeInformation.state!; - - // If we're restoring (pop/back or restoring a branch stack), skip onEnter - // but still run legacy + route-level redirects so state can be updated. - if (infoState is RouteInformationState && - infoState.type == NavigatingType.restore) { - final RouteMatchList restored = infoState.baseRouteMatchList!; - return debugParserFuture = _redirect(context, restored).then(( - RouteMatchList value, - ) { - if (value.isError && onParserException != null) { - return onParserException!(context, value); - } - _lastMatchList = value; - return value; - }); - } - - // Process legacy state if necessary. - if (infoState 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. Run redirects to allow state updates. - final RouteMatchList matchList = _routeMatchListCodec.decode( - infoState as Map, + // Normalize inputs into a RouteInformationState so we ALWAYS go through onEnter. + final Object? raw = routeInformation.state; + late final RouteInfoState infoState; + late final RouteInformation effectiveRoute; + + if (raw == null) { + // Framework/browser provided no state — synthesize a standard "go" nav. + // This happens on initial app load and some framework calls. + infoState = RouteInformationState.go(); + effectiveRoute = RouteInformation( + uri: routeInformation.uri, + state: infoState, ); - return debugParserFuture = _redirect(context, matchList).then(( - RouteMatchList value, - ) { - if (value.isError && onParserException != null) { - return onParserException!(context, value); - } - _lastMatchList = value; - return value; - }); + } else if (raw is! RouteInformationState) { + // Restoration/back-forward: decode the stored match list and treat as restore. + final RouteMatchList decoded = _routeMatchListCodec.decode( + raw as Map, + ); + infoState = RouteInformationState.restore(base: decoded); + effectiveRoute = RouteInformation(uri: decoded.uri, state: infoState); + } else { + infoState = raw; + effectiveRoute = routeInformation; } + // ALL navigation types now go through onEnter, and if allowed, + // legacy top-level redirect runs, then route-level redirects. return _onEnterHandler.handleTopOnEnter( context: context, - routeInformation: routeInformation, + routeInformation: effectiveRoute, infoState: infoState, - onCanEnter: () => _navigate(routeInformation, context, infoState), + onCanEnter: () { + // Compose legacy top-level redirect here (one shared cycle/history) + final Uri uri = RouteConfiguration.normalizeUri(effectiveRoute.uri); + final RouteMatchList initialMatches = configuration.findMatch( + uri, + extra: infoState.extra, + ); + final List redirectHistory = []; + + final FutureOr afterLegacy = configuration + .applyTopLegacyRedirect( + context, + initialMatches, + redirectHistory: redirectHistory, + ); + + if (afterLegacy is RouteMatchList) { + return _navigate( + effectiveRoute, + context, + infoState, + startingMatches: afterLegacy, + preSharedHistory: redirectHistory, + ); + } + return afterLegacy.then((RouteMatchList ml) { + return _navigate( + effectiveRoute, + context, + infoState, + startingMatches: ml, + preSharedHistory: redirectHistory, + ); + }); + }, onCanNotEnter: () { - // If navigation is blocked, return the last successful match or empty. + // If blocked, "stay" on last successful match if available. if (_lastMatchList != null) { return SynchronousFuture(_lastMatchList!); - } else { - return SynchronousFuture(RouteMatchList.empty); } + // Fall back to parsing the current URI so Router still paints something sensible. + final Uri uri = RouteConfiguration.normalizeUri(effectiveRoute.uri); + final RouteMatchList stay = configuration.findMatch( + uri, + extra: infoState.extra, + ); + return SynchronousFuture(stay); }, ); } @@ -137,60 +160,77 @@ class GoRouteInformationParser extends RouteInformationParser { /// the route match list based on the navigation type. /// /// This method is called ONLY AFTER onEnter has allowed the navigation. - /// It includes the deprecated redirect logic. Future _navigate( RouteInformation routeInformation, BuildContext context, - RouteInfoState infoState, - ) { - // Normalize the URI: ensure it has a valid path and remove trailing slashes. - Uri uri = routeInformation.uri; - if (uri.hasEmptyPath) { - uri = uri.replace(path: '/'); - } else if (uri.path.length > 1 && uri.path.endsWith('/')) { - uri = uri.replace(path: uri.path.substring(0, uri.path.length - 1)); - } + RouteInfoState infoState, { + FutureOr? startingMatches, + List? preSharedHistory, + }) { + // If we weren't given matches, compute them (normalized) here. + final FutureOr baseMatches = + startingMatches ?? + configuration.findMatch( + RouteConfiguration.normalizeUri(routeInformation.uri), + extra: infoState.extra, + ); - // Find initial route matches. - final RouteMatchList initialMatches = configuration.findMatch( - uri, - extra: infoState.extra, - ); - if (initialMatches.isError) { - log('No initial matches: ${routeInformation.uri.path}'); - } + // History may be shared with the legacy step done in onEnter. + final List redirectHistory = + preSharedHistory ?? []; - // Process the deprecated redirect AFTER onEnter has allowed navigation - return debugParserFuture = _redirect(context, initialMatches).then(( - RouteMatchList matchList, - ) { - if (matchList.isError && onParserException != null) { - return onParserException!(context, matchList); + FutureOr afterRouteLevel(FutureOr base) { + if (base is RouteMatchList) { + return configuration.redirect( + context, + base, + redirectHistory: redirectHistory, + ); } + return base.then((RouteMatchList ml) { + final FutureOr step = configuration.redirect( + context, + ml, + redirectHistory: redirectHistory, + ); + return step; + }); + } + + // Only route-level redirects from here on out. + final FutureOr redirected = afterRouteLevel(baseMatches); - // Validate that redirect-only routes actually perform a redirection. - 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}', + return debugParserFuture = (redirected is RouteMatchList + ? SynchronousFuture(redirected) + : redirected) + .then((RouteMatchList matchList) { + if (matchList.isError && onParserException != null) { + return onParserException!(context, matchList); + } + + // Validate that redirect-only routes actually perform a redirection. + 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}', + ); + } + return true; + }()); + + // Update the route match list based on the navigation type. + final RouteMatchList updated = _updateRouteMatchList( + matchList, + baseRouteMatchList: infoState.baseRouteMatchList, + completer: infoState.completer, + type: infoState.type, ); - } - return true; - }()); - - // Update the route match list based on the navigation type. - final RouteMatchList updated = _updateRouteMatchList( - matchList, - baseRouteMatchList: infoState.baseRouteMatchList, - completer: infoState.completer, - type: infoState.type, - ); - // Cache the successful match list. - _lastMatchList = updated; - return updated; - }); + // Cache the successful match list. + _lastMatchList = updated; + return updated; + }); } @override @@ -231,22 +271,6 @@ class GoRouteInformationParser extends RouteInformationParser { ); } - /// Calls [configuration.redirect] and wraps the result in a 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); - } - return redirectedFuture; - } - /// Updates the route match list based on the navigation type (push, replace, etc.). RouteMatchList _updateRouteMatchList( RouteMatchList newMatchList, { @@ -375,22 +399,17 @@ class _OnEnterHandler { required NavigationCallback onCanEnter, required NavigationCallback onCanNotEnter, }) { + // Get the user-provided onEnter callback (legacy redirect is handled separately) final OnEnter? topOnEnter = _configuration.topOnEnter; - // If no onEnter is configured, allow navigation immediately. + // If no onEnter guard, allow navigation immediately. if (topOnEnter == null) { return onCanEnter(); } - // Normalize the incoming URI (match what _navigate() does) - Uri normalizedUri = routeInformation.uri; - if (normalizedUri.hasEmptyPath) { - normalizedUri = normalizedUri.replace(path: '/'); - } else if (normalizedUri.path.length > 1 && - normalizedUri.path.endsWith('/')) { - normalizedUri = normalizedUri.replace( - path: normalizedUri.path.substring(0, normalizedUri.path.length - 1), - ); - } + // Normalize the incoming URI + final Uri normalizedUri = RouteConfiguration.normalizeUri( + routeInformation.uri, + ); // Check if the redirection history exceeds the configured limit. final RouteMatchList? redirectionErrorMatchList = diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index e7cafe5439f..4201dbb5e64 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -136,11 +136,9 @@ class RoutingConfig { /// /// The [onEnter] callback allows intercepting navigation before routes are /// processed. Return [Allow] to proceed or [Block] to prevent navigation. -/// This runs **before** any deprecated top-level [redirect] logic (which is -/// internally composed into `onEnter` for backward compatibility) and -/// **before** any *route-level* redirects. Order of operations: -/// 1) `onEnter` (your guard) -/// 2) legacy top-level `redirect` (if provided; executed via `onEnter` composition) +/// Order of operations: +/// 1) `onEnter` (your guard) - can block navigation +/// 2) If allowed: legacy top-level `redirect` (deprecated) - runs in same navigation cycle /// 3) route-level `GoRoute.redirect` /// /// The [redirect] callback allows the app to redirect to a new location. diff --git a/packages/go_router/test/on_enter_test.dart b/packages/go_router/test/on_enter_test.dart index 145927148e1..8acaf58ecd4 100644 --- a/packages/go_router/test/on_enter_test.dart +++ b/packages/go_router/test/on_enter_test.dart @@ -1081,7 +1081,7 @@ void main() { expect(capturedNextPath, equals('/protected')); }); - testWidgets('pop/restore does not call onEnter', ( + testWidgets('pop does not call onEnter but restore does', ( WidgetTester tester, ) async { int onEnterCount = 0; @@ -1118,9 +1118,90 @@ void main() { router.pop(); await tester.pumpAndSettle(); - // onEnter should NOT be called for restore - expect(onEnterCount, 2); // unchanged + // Pop calls restore which now goes through onEnter + expect(onEnterCount, 3); // onEnter called for restore expect(find.text('A'), findsOneWidget); + + // Explicit restore would call onEnter (tested separately in integration) + }); + + testWidgets('restore navigation calls onEnter for re-validation', ( + WidgetTester tester, + ) async { + int onEnterCount = 0; + bool allowNavigation = true; + + router = GoRouter( + initialLocation: '/home', + onEnter: (_, __, GoRouterState next, ____) async { + onEnterCount++; + // Simulate auth check - block protected route if not allowed + if (next.uri.path == '/protected' && !allowNavigation) { + return const Block(); + } + return const Allow(); + }, + routes: [ + GoRoute( + path: '/home', + builder: (_, __) => const Scaffold(body: Text('Home')), + ), + GoRoute( + path: '/protected', + builder: (_, __) => const Scaffold(body: Text('Protected')), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + expect(onEnterCount, 1); // initial navigation + + // Navigate to protected route (allowed) + router.go('/protected'); + await tester.pumpAndSettle(); + expect(onEnterCount, 2); + expect(find.text('Protected'), findsOneWidget); + + // Simulate state restoration by explicitly calling parser with restore type + final BuildContext context = tester.element(find.byType(Router)); + final GoRouteInformationParser parser = router.routeInformationParser; + + // Create a restore navigation to protected route + final RouteMatchList restoredMatch = await parser + .parseRouteInformationWithDependencies( + RouteInformation( + uri: Uri.parse('/protected'), + state: RouteInformationState( + type: NavigatingType.restore, + baseRouteMatchList: router.routerDelegate.currentConfiguration, + ), + ), + context, + ); + + // onEnter should be called again for restore + expect(onEnterCount, 3); + expect(restoredMatch.uri.path, equals('/protected')); + + // Now simulate session expired - block on restore + allowNavigation = false; + final RouteMatchList blockedRestore = await parser + .parseRouteInformationWithDependencies( + RouteInformation( + uri: Uri.parse('/protected'), + state: RouteInformationState( + type: NavigatingType.restore, + baseRouteMatchList: router.routerDelegate.currentConfiguration, + ), + ), + context, + ); + + // onEnter called again but blocks this time + expect(onEnterCount, 4); + // Should stay on protected since we're blocking but not redirecting + expect(blockedRestore.uri.path, equals('/protected')); }); testWidgets( @@ -1378,5 +1459,81 @@ void main() { // We're still on '/start' because the guarded nav never committed expect(router.state.uri.path, '/start'); }); + + testWidgets('restore runs onEnter -> legacy -> route-level redirect', ( + WidgetTester tester, + ) async { + final List calls = []; + + router = GoRouter( + initialLocation: '/home', + routes: [ + GoRoute( + path: '/home', + builder: (_, __) => const Scaffold(body: Text('Home')), + ), + GoRoute( + path: '/has-route-redirect', + builder: (_, __) => const Scaffold(body: Text('Never shown')), + redirect: (_, __) { + calls.add('route-level'); + return '/redirected'; + }, + ), + GoRoute( + path: '/redirected', + builder: (_, __) => const Scaffold(body: Text('Redirected')), + ), + ], + onEnter: (_, __, ___, ____) { + calls.add('onEnter'); + return const Allow(); + }, + // ignore: deprecated_member_use_from_same_package + redirect: (_, __) { + calls.add('legacy'); + return null; + }, + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + // Navigate to a route with route-level redirect + router.go('/has-route-redirect'); + await tester.pumpAndSettle(); + + // Verify execution order: onEnter -> legacy -> route-level + expect( + calls, + containsAllInOrder(['onEnter', 'legacy', 'route-level']), + ); + expect(router.state.uri.path, '/redirected'); + expect(find.text('Redirected'), findsOneWidget); + + // Clear calls for restore test + calls.clear(); + + // Simulate restore by parsing with restore type + final BuildContext context = tester.element(find.byType(Router)); + final GoRouteInformationParser parser = router.routeInformationParser; + + await parser.parseRouteInformationWithDependencies( + RouteInformation( + uri: Uri.parse('/has-route-redirect'), + state: RouteInformationState( + type: NavigatingType.restore, + baseRouteMatchList: router.routerDelegate.currentConfiguration, + ), + ), + context, + ); + + // Verify restore also follows same order + expect( + calls, + containsAllInOrder(['onEnter', 'legacy', 'route-level']), + ); + }); }); } From 9e37af985f69ea09d26e5e8c7fa05b16099874e9 Mon Sep 17 00:00:00 2001 From: omar-hanafy Date: Mon, 22 Sep 2025 23:02:22 +0300 Subject: [PATCH 25/27] [go_router] polish onEnter handling and docs --- packages/go_router/CHANGELOG.md | 4 +- .../example/lib/top_level_on_enter.dart | 46 +++++------- packages/go_router/lib/src/configuration.dart | 8 +-- packages/go_router/lib/src/on_enter.dart | 4 ++ packages/go_router/lib/src/parser.dart | 70 +++++++++++-------- packages/go_router/lib/src/router.dart | 19 ++--- 6 files changed, 72 insertions(+), 79 deletions(-) diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index 81cf91a372b..4fcbc850ddd 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,7 +1,6 @@ ## 16.3.0 -- Adds new top level `onEnter` callback with access to current and next route states. -- Deprecates top level `redirect` in favor of `onEnter`. +- Adds a top-level `onEnter` callback with access to current and next route states. ## 16.2.2 @@ -1221,3 +1220,4 @@ ## 0.1.0 - squatting on the package name (I'm not too proud to admit it) + 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 f4359d2e7da..31c1d6ee17a 100644 --- a/packages/go_router/example/lib/top_level_on_enter.dart +++ b/packages/go_router/example/lib/top_level_on_enter.dart @@ -4,6 +4,8 @@ // ignore_for_file: use_build_context_synchronously +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -90,12 +92,8 @@ class App extends StatelessWidget { ) async { // Example: fire-and-forget analytics for deep links; never block the nav if (next.uri.hasQuery || next.uri.hasFragment) { - await ReferralService.trackDeepLink(next.uri).catchError(( - Object e, - __, - ) { - debugPrint('Failed to track deep link: $e'); - }); + // Don't await: keep the guard non-blocking for best UX. + unawaited(ReferralService.trackDeepLink(next.uri)); } switch (next.uri.path) { @@ -207,31 +205,21 @@ class App extends StatelessWidget { BuildContext context, String code, ) async { - try { - final bool ok = await ReferralService.processReferralCode(code); - if (!context.mounted) { - return; - } + final bool ok = await ReferralService.processReferralCode(code); + if (!context.mounted) { + return; + } - // Show result with a simple SnackBar - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - ok - ? 'Referral code $code applied successfully!' - : 'Failed to apply referral code', - ), + // Show result with a simple SnackBar + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + ok + ? 'Referral code $code applied successfully!' + : 'Failed to apply referral code', ), - ); - } catch (e) { - if (!context.mounted) { - return; - } - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red), - ); - } + ), + ); } /// Handles OAuth tokens with minimal UI interaction diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index c9ebea6ef81..be191fc9f0d 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -246,8 +246,8 @@ class RouteConfiguration { /// The list of top level routes used by [GoRouterDelegate]. List get routes => _routingConfig.value.routes; - /// Top level page redirect (deprecated). - /// This is handled via applyTopLegacyRedirect and runs at most once per navigation. + /// Legacy top level page redirect. + /// This is handled via [applyTopLegacyRedirect] and runs at most once per navigation. GoRouterRedirect get topRedirect => _routingConfig.value.redirect; /// Top level page on enter. @@ -281,7 +281,7 @@ class RouteConfiguration { /// * [extra_codec](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/extra_codec.dart) /// example. /// * [topOnEnter] for navigation interception. - /// * [topRedirect] (deprecated) for legacy redirections. + /// * [topRedirect] for legacy redirections. final Codec? extraCodec; final Map _nameToPath = {}; @@ -447,7 +447,7 @@ class RouteConfiguration { return prevMatchListFuture.then(processRedirect); } - /// Applies the (deprecated) top-level redirect to [prevMatchList] and returns the + /// Applies the legacy top-level redirect to [prevMatchList] and returns the /// resulting matches. Returns [prevMatchList] when no redirect happens. /// Shares [redirectHistory] with later route-level redirects for proper loop detection. /// diff --git a/packages/go_router/lib/src/on_enter.dart b/packages/go_router/lib/src/on_enter.dart index 7f6beb31dac..88a44d5441e 100644 --- a/packages/go_router/lib/src/on_enter.dart +++ b/packages/go_router/lib/src/on_enter.dart @@ -38,5 +38,9 @@ final class Block extends OnEnterResult { /// **History behavior:** a plain `Block()` (no `then`) is a "hard stop" and /// resets `onEnter`'s internal redirection history so subsequent attempts are /// evaluated fresh; `Block(then: ...)` keeps history to detect loops. + /// + /// Note: We don't introspect callback bodies. Even an empty closure + /// (`Block(then: () {})`) counts as chaining. Omit `then` entirely when you + /// want the hard stop behavior. const Block({super.then}); } diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index 208e01a29bc..b711274c43e 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -77,28 +77,32 @@ class GoRouteInformationParser extends RouteInformationParser { // Normalize inputs into a RouteInformationState so we ALWAYS go through onEnter. final Object? raw = routeInformation.state; late final RouteInfoState infoState; + late final Uri incomingUri; late final RouteInformation effectiveRoute; if (raw == null) { // Framework/browser provided no state — synthesize a standard "go" nav. // This happens on initial app load and some framework calls. infoState = RouteInformationState.go(); - effectiveRoute = RouteInformation( - uri: routeInformation.uri, - state: infoState, - ); + incomingUri = routeInformation.uri; } else if (raw is! RouteInformationState) { // Restoration/back-forward: decode the stored match list and treat as restore. final RouteMatchList decoded = _routeMatchListCodec.decode( raw as Map, ); infoState = RouteInformationState.restore(base: decoded); - effectiveRoute = RouteInformation(uri: decoded.uri, state: infoState); + incomingUri = decoded.uri; } else { infoState = raw; - effectiveRoute = routeInformation; + incomingUri = routeInformation.uri; } + // Normalize once so downstream steps can assume the URI is canonical. + effectiveRoute = RouteInformation( + uri: RouteConfiguration.normalizeUri(incomingUri), + state: infoState, + ); + // ALL navigation types now go through onEnter, and if allowed, // legacy top-level redirect runs, then route-level redirects. return _onEnterHandler.handleTopOnEnter( @@ -106,10 +110,9 @@ class GoRouteInformationParser extends RouteInformationParser { routeInformation: effectiveRoute, infoState: infoState, onCanEnter: () { - // Compose legacy top-level redirect here (one shared cycle/history) - final Uri uri = RouteConfiguration.normalizeUri(effectiveRoute.uri); + // Compose legacy top-level redirect here (one shared cycle/history). final RouteMatchList initialMatches = configuration.findMatch( - uri, + effectiveRoute.uri, extra: infoState.extra, ); final List redirectHistory = []; @@ -145,19 +148,27 @@ class GoRouteInformationParser extends RouteInformationParser { if (_lastMatchList != null) { return SynchronousFuture(_lastMatchList!); } - // Fall back to parsing the current URI so Router still paints something sensible. - final Uri uri = RouteConfiguration.normalizeUri(effectiveRoute.uri); - final RouteMatchList stay = configuration.findMatch( - uri, + + // No prior route to restore (e.g., an initial deeplink was blocked). + // Surface an error so the app decides how to recover via onException. + final RouteMatchList blocked = _OnEnterHandler._errorRouteMatchList( + effectiveRoute.uri, + GoException( + 'Navigation to ${effectiveRoute.uri} was blocked by onEnter with no prior route to restore', + ), extra: infoState.extra, ); - return SynchronousFuture(stay); + final RouteMatchList resolved = + onParserException != null + ? onParserException!(context, blocked) + : blocked; + return SynchronousFuture(resolved); }, ); } - /// Normalizes the URI, finds matching routes, processes redirects, and updates - /// the route match list based on the navigation type. + /// Finds matching routes, processes redirects, and updates the route match + /// list based on the navigation type. /// /// This method is called ONLY AFTER onEnter has allowed the navigation. Future _navigate( @@ -167,13 +178,11 @@ class GoRouteInformationParser extends RouteInformationParser { FutureOr? startingMatches, List? preSharedHistory, }) { - // If we weren't given matches, compute them (normalized) here. + // If we weren't given matches, compute them here. The URI has already been + // normalized at the parser entry point. final FutureOr baseMatches = startingMatches ?? - configuration.findMatch( - RouteConfiguration.normalizeUri(routeInformation.uri), - extra: infoState.extra, - ); + configuration.findMatch(routeInformation.uri, extra: infoState.extra); // History may be shared with the legacy step done in onEnter. final List redirectHistory = @@ -406,14 +415,10 @@ class _OnEnterHandler { return onCanEnter(); } - // Normalize the incoming URI - final Uri normalizedUri = RouteConfiguration.normalizeUri( - routeInformation.uri, - ); - // Check if the redirection history exceeds the configured limit. + // `routeInformation` has already been normalized by the parser entrypoint. final RouteMatchList? redirectionErrorMatchList = - _redirectionErrorMatchList(context, normalizedUri, infoState); + _redirectionErrorMatchList(context, routeInformation.uri, infoState); if (redirectionErrorMatchList != null) { // Return immediately if the redirection limit is exceeded. @@ -422,7 +427,7 @@ class _OnEnterHandler { // Find route matches for the normalized URI. final RouteMatchList incomingMatches = _configuration.findMatch( - normalizedUri, + routeInformation.uri, extra: infoState.extra, ); @@ -485,15 +490,20 @@ class _OnEnterHandler { ); matchList = await onCanNotEnter(); - // Hard stop (no then callback): reset history so user retries don't trigger limit + // Treat `Block()` (no callback) as the explicit hard stop. + // We intentionally don't try to detect "no-op" callbacks; any + // Block with `then` keeps history so chained guards can detect loops. final bool hardStop = result is Block && callback == null; if (hardStop) { _resetRedirectionHistory(); } - // For chaining blocks (with then), keep history to detect loops + // For chaining blocks (with then), keep history to detect loops. } if (callback != null) { + // Schedule outside the parse cycle to avoid re-entrancy if the + // callback triggers navigation or UI work. Errors are reported but + // the committed navigation remains intact. unawaited( Future.microtask(callback).catchError(( Object error, diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index 525f1cccbf9..8c53949772d 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -54,10 +54,6 @@ class RoutingConfig { const RoutingConfig({ required this.routes, this.onEnter, - @Deprecated( - 'Use onEnter instead. ' - 'This feature will be removed in a future release.', - ) this.redirect = _defaultRedirect, this.redirectLimit = 5, }); @@ -83,11 +79,10 @@ class RoutingConfig { /// implemented), a re-evaluation will be triggered when the [InheritedWidget] /// changes. /// - /// See [GoRouter]. - @Deprecated( - 'Use onEnter for redirection. In the onEnter callback, call a navigation ' - 'method like router.go() and return const Block(). ', - ) + /// This legacy callback remains supported alongside [onEnter]. If both are + /// provided, [onEnter] executes first and may block the navigation. When + /// allowed, this callback runs once per navigation cycle before any + /// route-level redirects. final GoRouterRedirect redirect; /// The maximum number of redirection allowed. @@ -138,7 +133,7 @@ class RoutingConfig { /// processed. Return [Allow] to proceed or [Block] to prevent navigation. /// Order of operations: /// 1) `onEnter` (your guard) - can block navigation -/// 2) If allowed: legacy top-level `redirect` (deprecated) - runs in same navigation cycle +/// 2) If allowed: legacy top-level `redirect` - runs in same navigation cycle /// 3) route-level `GoRoute.redirect` /// /// The [redirect] callback allows the app to redirect to a new location. @@ -186,10 +181,6 @@ class GoRouter implements RouterConfig { GoExceptionHandler? onException, GoRouterPageBuilder? errorPageBuilder, GoRouterWidgetBuilder? errorBuilder, - @Deprecated( - 'Use onEnter for redirection. In the onEnter callback, call a navigation ' - 'method like router.go() and return const Block(). ', - ) GoRouterRedirect? redirect, int redirectLimit = 5, Listenable? refreshListenable, From 20c41485861c3186fffbcfd508f043c19da5948d Mon Sep 17 00:00:00 2001 From: omar-hanafy Date: Thu, 25 Sep 2025 14:34:38 +0300 Subject: [PATCH 26/27] [go_router] Refine onEnter documentation and API adjustments --- .../example/lib/top_level_on_enter.dart | 13 ++--- packages/go_router/lib/go_router.dart | 3 +- packages/go_router/lib/src/configuration.dart | 6 ++- packages/go_router/lib/src/on_enter.dart | 47 +++++++++++-------- packages/go_router/lib/src/parser.dart | 38 +++++++-------- packages/go_router/lib/src/router.dart | 4 +- packages/go_router/pubspec.yaml | 2 +- packages/go_router/test/on_enter_test.dart | 47 +++++++++---------- 8 files changed, 81 insertions(+), 79 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 31c1d6ee17a..bad12af0a0a 100644 --- a/packages/go_router/example/lib/top_level_on_enter.dart +++ b/packages/go_router/example/lib/top_level_on_enter.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// 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. @@ -83,7 +83,8 @@ class App extends StatelessWidget { /// Top-level guard runs BEFORE legacy top-level redirects and route-level redirects. /// Return: /// - `Allow()` to proceed (optionally with `then:` side-effects) - /// - `Block()` to cancel navigation (optionally with `then:` to defer an action/redirect) + /// - `Block.stop()` to cancel navigation immediately + /// - `Block.then(() => ...)` to cancel navigation and run follow-up work onEnter: ( BuildContext context, GoRouterState current, @@ -114,7 +115,7 @@ class App extends StatelessWidget { // Do the real work in the background; don’t keep the user waiting. await _processReferralCodeInBackground(context, code); } - return const Block(); // keep user where they are + return const Block.stop(); // keep user where they are } // Simulate an OAuth callback: do background work + toast; never show a page at /auth @@ -123,7 +124,7 @@ class App extends StatelessWidget { final String? token = next.uri.queryParameters['token']; if (token != null) { _handleAuthToken(context, token); - return const Block(); // cancel showing any /auth page + return const Block.stop(); // cancel showing any /auth page } return const Allow(); } @@ -140,7 +141,7 @@ class App extends StatelessWidget { // Chaining block: cancel the original nav, then redirect to /login. // This preserves redirection history to detect loops. final String from = Uri.encodeComponent(next.uri.toString()); - return Block(then: () => router.go('/login?from=$from')); + return Block.then(() => router.go('/login?from=$from')); } // ignore: dead_code return const Allow(); @@ -312,7 +313,7 @@ class HomeScreen extends StatelessWidget { const _DeepLinkButton( label: 'Protected Route (redirects to login)', path: '/protected', - description: 'Top-level onEnter returns Block(then: go(...))', + description: 'Top-level onEnter returns Block.then(() => go(...))', ), const SizedBox(height: 8), const _DeepLinkButton( diff --git a/packages/go_router/lib/go_router.dart b/packages/go_router/lib/go_router.dart index aa6edb35059..8ec3fb22ba2 100644 --- a/packages/go_router/lib/go_router.dart +++ b/packages/go_router/lib/go_router.dart @@ -15,7 +15,8 @@ export 'src/misc/custom_parameter.dart'; export 'src/misc/errors.dart'; export 'src/misc/extensions.dart'; export 'src/misc/inherited_router.dart'; -export 'src/on_enter.dart' show Allow, Block, OnEnterResult; +export 'src/on_enter.dart' + show Allow, Block, OnEnterResult, OnEnterThenCallback; export 'src/pages/custom_transition_page.dart'; export 'src/parser.dart'; export 'src/route.dart'; diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index 033d39b116e..14fd8156991 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -247,6 +247,7 @@ class RouteConfiguration { List get routes => _routingConfig.value.routes; /// Legacy top level page redirect. + /// /// This is handled via [applyTopLegacyRedirect] and runs at most once per navigation. GoRouterRedirect get topRedirect => _routingConfig.value.redirect; @@ -394,8 +395,9 @@ class RouteConfiguration { return const []; } - /// Processes route-level redirects by returning a new [RouteMatchList] representing the new - /// location. This method now handles ONLY route-level redirects. + /// Processes route-level redirects by returning a new [RouteMatchList] representing the new location. + /// + /// This method now handles ONLY route-level redirects. /// Top-level redirects are handled by applyTopLegacyRedirect. FutureOr redirect( BuildContext context, diff --git a/packages/go_router/lib/src/on_enter.dart b/packages/go_router/lib/src/on_enter.dart index 88a44d5441e..cb152b30617 100644 --- a/packages/go_router/lib/src/on_enter.dart +++ b/packages/go_router/lib/src/on_enter.dart @@ -1,9 +1,12 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// 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 'dart:async'; +/// Signature for callbacks invoked after an [OnEnterResult] is resolved. +typedef OnEnterThenCallback = FutureOr Function(); + /// The result of an onEnter callback. /// /// This sealed class represents the possible outcomes of navigation interception. @@ -15,32 +18,36 @@ sealed class OnEnterResult { /// Executed after the decision is committed. /// Errors are reported and do not revert navigation. - final FutureOr Function()? then; + final OnEnterThenCallback? then; } /// Allows the navigation to proceed. +/// +/// The [then] callback runs **after** the navigation is committed. Errors +/// thrown by this callback are reported via `FlutterError.reportError` and +/// do **not** undo the already-committed navigation. final class Allow extends OnEnterResult { - /// Creates an [Allow] result. - /// - /// The [then] callback runs **after** the navigation is committed. Errors - /// thrown by this callback are reported via `FlutterError.reportError` and - /// do **not** undo the already-committed navigation. + /// Creates an [Allow] result with an optional [then] callback executed after + /// navigation completes. const Allow({super.then}); } /// Blocks the navigation from proceeding. +/// +/// Use [Block.stop] for a "hard stop" that resets the redirection history, or +/// [Block.then] to chain a callback after the block (commonly to redirect +/// elsewhere, e.g. `router.go('/login')`). +/// +/// Note: We don't introspect callback bodies. Even an empty closure still +/// counts as chaining, so prefer [Block.stop] when you want the hard stop +/// behavior. final class Block extends OnEnterResult { - /// Creates a [Block] result. - /// - /// The [then] callback is executed after the navigation is blocked. - /// Commonly used to navigate to a different route (e.g. `router.go('/login')`). - /// - /// **History behavior:** a plain `Block()` (no `then`) is a "hard stop" and - /// resets `onEnter`'s internal redirection history so subsequent attempts are - /// evaluated fresh; `Block(then: ...)` keeps history to detect loops. - /// - /// Note: We don't introspect callback bodies. Even an empty closure - /// (`Block(then: () {})`) counts as chaining. Omit `then` entirely when you - /// want the hard stop behavior. - const Block({super.then}); + /// Creates a [Block] that stops navigation without running a follow-up + /// callback. Resets the redirection history so the next attempt is evaluated + /// fresh. + const Block.stop() : super(); + + /// Creates a [Block] that runs [then] after the navigation is blocked. + /// Keeps the redirection history to detect loops during chained redirects. + const Block.then(OnEnterThenCallback then) : super(then: then); } diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index f14abad5eae..4705f88f72d 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -478,7 +478,7 @@ class _OnEnterHandler { return onEnterResultFuture.then( (OnEnterResult result) async { RouteMatchList matchList; - final FutureOr Function()? callback = result.then; + final OnEnterThenCallback? callback = result.then; if (result is Allow) { matchList = await onCanEnter(); @@ -490,7 +490,7 @@ class _OnEnterHandler { ); matchList = await onCanNotEnter(); - // Treat `Block()` (no callback) as the explicit hard stop. + // Treat `Block.stop()` as the explicit hard stop. // We intentionally don't try to detect "no-op" callbacks; any // Block with `then` keeps history so chained guards can detect loops. final bool hardStop = result is Block && callback == null; @@ -501,26 +501,20 @@ class _OnEnterHandler { } if (callback != null) { - // Schedule outside the parse cycle to avoid re-entrancy if the - // callback triggers navigation or UI work. Errors are reported but - // the committed navigation remains intact. - unawaited( - Future.microtask(callback).catchError(( - Object error, - StackTrace stack, - ) { - // Log error but don't crash - navigation already committed - log('Error in then callback: $error'); - FlutterError.reportError( - FlutterErrorDetails( - exception: error, - stack: stack, - library: 'go_router', - context: ErrorDescription('while executing then callback'), - ), - ); - }), - ); + try { + await Future.sync(callback); + } catch (error, stack) { + // Log error but don't crash - navigation already committed + log('Error in then callback: $error'); + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stack, + library: 'go_router', + context: ErrorDescription('while executing then callback'), + ), + ); + } } return matchList; diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index f212dcd0ec1..3ce5927dc43 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -107,11 +107,11 @@ class RoutingConfig { /// onEnter: (BuildContext context, GoRouterState current, /// GoRouterState next, GoRouter router) async { /// if (next.uri.path == '/login' && isUserLoggedIn()) { - /// return const Block(); // Prevent navigation to /login + /// return const Block.stop(); // Prevent navigation to /login /// } /// if (next.uri.path == '/protected' && !isUserLoggedIn()) { /// // Block and redirect to login - /// return Block(then: () => router.go('/login?from=${next.uri}')); + /// return Block.then(() => router.go('/login?from=${next.uri}')); /// } /// return const Allow(); // Allow navigation /// }, diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index 68866a848c6..de30797b6e0 100644 --- a/packages/go_router/pubspec.yaml +++ b/packages/go_router/pubspec.yaml @@ -30,4 +30,4 @@ dev_dependencies: topics: - deep-linking - go-router - - navigation \ No newline at end of file + - navigation diff --git a/packages/go_router/test/on_enter_test.dart b/packages/go_router/test/on_enter_test.dart index 8acaf58ecd4..58f43d3a690 100644 --- a/packages/go_router/test/on_enter_test.dart +++ b/packages/go_router/test/on_enter_test.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// 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. @@ -74,7 +74,7 @@ void main() { navigationAttempts.add(next.uri.path); currentPath = current.uri.path; return next.uri.path.contains('blocked') - ? const Block() + ? const Block.stop() : const Allow(); }, routes: [ @@ -144,7 +144,7 @@ void main() { ) async { onEnterCallCount++; return next.uri.path.contains('block') - ? const Block() + ? const Block.stop() : const Allow(); }, routes: [ @@ -213,7 +213,7 @@ void main() { GoRouter goRouter, ) async { if (next.uri.path == '/recursive') { - return Block(then: () => goRouter.push('/recursive')); + return Block.then(() => goRouter.push('/recursive')); } return const Allow(); }, @@ -287,7 +287,7 @@ void main() { if (await isAuthenticated()) { return const Allow(); } - return Block(then: () => router.go('/sign-in')); + return Block.then(() => router.go('/sign-in')); }, routes: [ GoRoute( @@ -350,14 +350,11 @@ void main() { navigationAttempts.add(next.uri.path); if (next.uri.path == '/requires-auth') { - return Block( - then: - () => goRouter.goNamed( - 'login-page', - queryParameters: { - 'from': next.uri.toString(), - }, - ), + return Block.then( + () => goRouter.goNamed( + 'login-page', + queryParameters: {'from': next.uri.toString()}, + ), ); } return const Allow(); @@ -448,7 +445,7 @@ void main() { } }); - return const Block(); + return const Block.stop(); }, routes: [ GoRoute( @@ -515,7 +512,7 @@ void main() { if (next.uri.path == '/old-page') { navigationHistory.add('Replacing with /new-version'); await goRouter.replace('/new-version'); - return const Block(); + return const Block.stop(); } return const Allow(); }, @@ -576,7 +573,7 @@ void main() { if (next.uri.path == '/outdated') { navigationLog.add('Push replacing with /updated'); await goRouter.pushReplacement('/updated'); - return const Block(); + return const Block.stop(); } return const Allow(); }, @@ -699,7 +696,7 @@ void main() { }, ); // 2. Block the original navigation to '/protected' - return const Block(); + return const Block.stop(); }, routes: [ GoRoute( @@ -835,7 +832,7 @@ void main() { // Step 1: Go to a different route navigationChain.add('Step 1: Go to /step-one'); // We're blocking the original navigation and deferring the go - return Block(then: () => goRouter.go('/step-one')); + return Block.then(() => goRouter.go('/step-one')); } // When we reach step-one, mark test as complete @@ -988,7 +985,7 @@ void main() { onEnterCallCount++; lastOnEnterBlocked = next.uri.path == '/blocked'; if (lastOnEnterBlocked) { - return const Block(); + return const Block.stop(); } return const Allow(); }, @@ -1053,7 +1050,7 @@ void main() { capturedNextPath = next.uri.path; if (next.uri.path == '/protected') { - return const Block(); + return const Block.stop(); } return const Allow(); }, @@ -1137,7 +1134,7 @@ void main() { onEnterCount++; // Simulate auth check - block protected route if not allowed if (next.uri.path == '/protected' && !allowNavigation) { - return const Block(); + return const Block.stop(); } return const Allow(); }, @@ -1390,8 +1387,8 @@ void main() { WidgetTester tester, ) async { // With redirectLimit=1: - // - Block() (no then) should reset history so repeated attempts don't hit the limit. - // - Block(then: go(...)) keeps history and will exceed the limit. + // - Block.stop() resets history so repeated attempts don't hit the limit. + // - Block.then(() => go(...)) keeps history and will exceed the limit. int onExceptionCalls = 0; final Completer exceededCompleter = Completer(); @@ -1407,11 +1404,11 @@ void main() { onEnter: (_, __, GoRouterState next, GoRouter goRouter) async { if (next.uri.path == '/blocked-once') { // Hard stop: no then -> history should reset - return const Block(); + return const Block.stop(); } if (next.uri.path == '/chain') { // Chaining block: keep history -> will exceed limit - return Block(then: () => goRouter.go('/chain')); + return Block.then(() => goRouter.go('/chain')); } return const Allow(); }, From 2b4c348c3a11a8db257377e42de0916332de50d1 Mon Sep 17 00:00:00 2001 From: omar-hanafy Date: Sat, 27 Sep 2025 11:08:36 +0300 Subject: [PATCH 27/27] [go_router] Improve context safety and docs in navigation logic Adds context.mounted checks in GoRouteInformationParser and _OnEnterHandler to prevent actions on disposed contexts, improving safety during navigation and error handling. Enhances documentation for OnEnterResult and Block classes to clarify navigation blocking behavior. Removes unnecessary ignore_for_file: use_build_context_synchronously directives. --- .../example/lib/top_level_on_enter.dart | 2 - packages/go_router/lib/src/on_enter.dart | 14 +++++- packages/go_router/lib/src/parser.dart | 47 +++++++++++++------ 3 files changed, 45 insertions(+), 18 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 bad12af0a0a..fc0fa1e5871 100644 --- a/packages/go_router/example/lib/top_level_on_enter.dart +++ b/packages/go_router/example/lib/top_level_on_enter.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// ignore_for_file: use_build_context_synchronously - import 'dart:async'; import 'package:flutter/material.dart'; diff --git a/packages/go_router/lib/src/on_enter.dart b/packages/go_router/lib/src/on_enter.dart index cb152b30617..329f587a07d 100644 --- a/packages/go_router/lib/src/on_enter.dart +++ b/packages/go_router/lib/src/on_enter.dart @@ -19,6 +19,9 @@ sealed class OnEnterResult { /// Executed after the decision is committed. /// Errors are reported and do not revert navigation. final OnEnterThenCallback? then; + + /// Whether this block represents a hard stop without a follow-up callback. + bool get isStop => this is Block && then == null; } /// Allows the navigation to proceed. @@ -34,6 +37,9 @@ final class Allow extends OnEnterResult { /// Blocks the navigation from proceeding. /// +/// Returning an object of this class from an `onEnter` callback halts the +/// navigation completely. +/// /// Use [Block.stop] for a "hard stop" that resets the redirection history, or /// [Block.then] to chain a callback after the block (commonly to redirect /// elsewhere, e.g. `router.go('/login')`). @@ -43,11 +49,15 @@ final class Allow extends OnEnterResult { /// behavior. final class Block extends OnEnterResult { /// Creates a [Block] that stops navigation without running a follow-up - /// callback. Resets the redirection history so the next attempt is evaluated - /// fresh. + /// callback. + /// + /// Returning an object created by this constructor from an `onEnter` + /// callback halts the navigation completely and resets the redirection + /// history so the next attempt is evaluated fresh. const Block.stop() : super(); /// Creates a [Block] that runs [then] after the navigation is blocked. + /// /// Keeps the redirection history to detect loops during chained redirects. const Block.then(OnEnterThenCallback then) : super(then: then); } diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index 4705f88f72d..b5a558959a5 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -2,7 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// ignore_for_file: use_build_context_synchronously import 'dart:async'; import 'dart:math'; @@ -134,6 +133,16 @@ class GoRouteInformationParser extends RouteInformationParser { ); } return afterLegacy.then((RouteMatchList ml) { + if (!context.mounted) { + return _lastMatchList ?? + _OnEnterHandler._errorRouteMatchList( + effectiveRoute.uri, + GoException( + 'Navigation aborted because the router context was disposed.', + ), + extra: infoState.extra, + ); + } return _navigate( effectiveRoute, context, @@ -197,6 +206,9 @@ class GoRouteInformationParser extends RouteInformationParser { ); } return base.then((RouteMatchList ml) { + if (!context.mounted) { + return ml; + } final FutureOr step = configuration.redirect( context, ml, @@ -214,6 +226,9 @@ class GoRouteInformationParser extends RouteInformationParser { : redirected) .then((RouteMatchList matchList) { if (matchList.isError && onParserException != null) { + if (!context.mounted) { + return matchList; + } return onParserException!(context, matchList); } @@ -467,11 +482,14 @@ class _OnEnterHandler { _resetRedirectionHistory(); - return SynchronousFuture( - _onParserException != null - ? _onParserException(context, errorMatchList) - : errorMatchList, - ); + final bool canHandleException = + _onParserException != null && context.mounted; + final RouteMatchList handledMatchList = + canHandleException + ? _onParserException(context, errorMatchList) + : errorMatchList; + + return SynchronousFuture(handledMatchList); } // Handle asynchronous completion and catch any errors. @@ -493,8 +511,7 @@ class _OnEnterHandler { // Treat `Block.stop()` as the explicit hard stop. // We intentionally don't try to detect "no-op" callbacks; any // Block with `then` keeps history so chained guards can detect loops. - final bool hardStop = result is Block && callback == null; - if (hardStop) { + if (result.isStop) { _resetRedirectionHistory(); } // For chaining blocks (with then), keep history to detect loops. @@ -529,9 +546,10 @@ class _OnEnterHandler { extra: infoState.extra, ); - return _onParserException != null - ? _onParserException(context, errorMatchList) - : errorMatchList; + if (_onParserException != null && context.mounted) { + return _onParserException(context, errorMatchList); + } + return errorMatchList; }, ); } @@ -609,9 +627,10 @@ class _OnEnterHandler { extra: infoState.extra, ); _resetRedirectionHistory(); - return _onParserException != null - ? _onParserException(context, errorMatchList) - : errorMatchList; + if (_onParserException != null && context.mounted) { + return _onParserException(context, errorMatchList); + } + return errorMatchList; } return null; }