diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index 5ed3009f964..f4f716703a4 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,7 @@ +## 16.3.0 + +- Adds a top-level `onEnter` callback with access to current and next route states. + ## 16.2.5 - Fixes `GoRouter.of(context)` access inside redirect callbacks by providing router access through Zone-based context tracking. 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..fc0fa1e5871 --- /dev/null +++ b/packages/go_router/example/lib/top_level_on_enter.dart @@ -0,0 +1,452 @@ +// 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/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 { + /// The main application widget. + const App({super.key}); + + @override + Widget build(BuildContext context) { + final GlobalKey key = GlobalKey(); + + return MaterialApp.router( + routerConfig: _router(key), + title: 'Top-level onEnter', + ); + } + + /// Configures the router with navigation handling and deep link support. + GoRouter _router(GlobalKey key) { + return GoRouter( + navigatorKey: key, + initialLocation: '/home', + debugLogDiagnostics: true, + + // If anything goes sideways during parsing/guards/redirects, + // surface a friendly message and offer a one-tap “Go Home”. + 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'); + } + }, + + /// Top-level guard runs BEFORE legacy top-level redirects and route-level redirects. + /// Return: + /// - `Allow()` to proceed (optionally with `then:` side-effects) + /// - `Block.stop()` to cancel navigation immediately + /// - `Block.then(() => ...)` to cancel navigation and run follow-up work + onEnter: ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter router, + ) async { + // Example: fire-and-forget analytics for deep links; never block the nav + if (next.uri.hasQuery || next.uri.hasFragment) { + // Don't await: keep the guard non-blocking for best UX. + unawaited(ReferralService.trackDeepLink(next.uri)); + } + + 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 = 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); + } + return const Block.stop(); // keep user where they are + } + + // Simulate an OAuth callback: do background work + toast; never show a page at /auth + case '/auth': + { + final String? token = next.uri.queryParameters['token']; + if (token != null) { + _handleAuthToken(context, token); + return const Block.stop(); // cancel showing any /auth page + } + return const Allow(); + } + + // Demonstrate error reporting path + case '/crash-test': + throw Exception('Simulated error in onEnter callback!'); + + 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: + return const Allow(); + } + }, + + routes: [ + // Simple “root → home” + GoRoute( + path: '/', + 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: (_, __) => const SizedBox.shrink(), + ), + + // Route-level redirect happens AFTER top-level onEnter allows. + GoRoute( + path: '/old', + builder: (_, __) => const SizedBox.shrink(), + redirect: (_, __) => '/home?from=old', + ), + + // A page that shows fragments (#hash) via state.uri.fragment + GoRoute( + 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()), + ], + ); + } + + /// Processes referral code in the background without blocking navigation + Future _processReferralCodeInBackground( + BuildContext context, + String code, + ) async { + 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', + ), + ), + ); + } + + /// Handles OAuth tokens with minimal UI interaction + void _handleAuthToken(BuildContext context, String token) { + if (!context.mounted) { + return; + } + + // Just show feedback, avoid complex UI + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Processing auth token: $token'), + duration: const Duration(seconds: 2), + ), + ); + // background processing — keeps UI responsive and avoids re-entrancy + Future(() async { + await Future.delayed(const Duration(seconds: 1)); + if (!context.mounted) { + return; + } + + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Auth token processed: $token'))); + }); + } +} + +/// 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) { + 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'), + actions: [ + IconButton( + icon: const Icon(Icons.settings), + onPressed: () => context.go('/settings'), + ), + ], + ), + 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), + + 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', + ), + + 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, + ), + ], + ), + ); + } +} + +/// 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 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, + ), + ), + ], + ); + } +} + +/// 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('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 +class SettingsScreen extends StatelessWidget { + /// Settings screen implementation + const SettingsScreen({super.key}); + + @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'), + ), + ], + ), + ); +} + +/// 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/go_router.dart b/packages/go_router/lib/go_router.dart index 04fc0791ed8..8ec3fb22ba2 100644 --- a/packages/go_router/lib/go_router.dart +++ b/packages/go_router/lib/go_router.dart @@ -15,9 +15,12 @@ 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, OnEnterThenCallback; 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 683c88f8ed7..8e070a8d1cf 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -14,7 +14,7 @@ import 'misc/constants.dart'; import 'misc/errors.dart'; import 'path_utils.dart'; import 'route.dart'; -import 'router.dart'; +import 'router.dart' show GoRouter, OnEnter, RoutingConfig; import 'state.dart'; /// The signature of the redirect callback. @@ -91,7 +91,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) { @@ -143,16 +143,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', ); @@ -236,6 +238,7 @@ class RouteConfiguration { extra: matchList.extra, pageKey: const ValueKey('topLevel'), topRoute: matchList.lastOrNull?.route, + error: matchList.error, ); } @@ -245,12 +248,27 @@ class RouteConfiguration { /// The list of top level routes used by [GoRouterDelegate]. List get routes => _routingConfig.value.routes; - /// Top level page redirect. + /// 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. + OnEnter? get topOnEnter => _routingConfig.value.onEnter; + /// 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; @@ -265,6 +283,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] for legacy redirections. final Codec? extraCodec; /// The GoRouter instance that owns this configuration. @@ -382,8 +402,10 @@ 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, { @@ -391,107 +413,133 @@ class RouteConfiguration { }) { FutureOr processRedirect(RouteMatchList prevMatchList) { final String prevLocation = prevMatchList.uri.toString(); - FutureOr processTopLevelRedirect( - String? topRedirectLocation, + + FutureOr processRouteLevelRedirect( + String? routeRedirectLocation, ) { - if (topRedirectLocation != null && - topRedirectLocation != prevLocation) { + if (routeRedirectLocation != null && + routeRedirectLocation != prevLocation) { final RouteMatchList newMatch = _getNewMatches( - topRedirectLocation, + routeRedirectLocation, prevMatchList.uri, redirectHistory, ); + if (newMatch.isError) { return newMatch; } return redirect(context, newMatch, redirectHistory: redirectHistory); } + return prevMatchList; + } - 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 List routeMatches = []; - prevMatchList.visitRouteMatches((RouteMatchBase match) { - if (match.route.redirect != null) { - routeMatches.add(match); - } - return true; - }); - - try { - final FutureOr routeLevelRedirectResult = - _getRouteLevelRedirect(context, prevMatchList, routeMatches, 0); + try { + final FutureOr routeLevelRedirectResult = + _getRouteLevelRedirect(context, prevMatchList, routeMatches, 0); - if (routeLevelRedirectResult is String?) { - return processRouteLevelRedirect(routeLevelRedirectResult); - } - return routeLevelRedirectResult - .then(processRouteLevelRedirect) - .catchError((Object error) { - final GoException goException = - error is GoException - ? error - : GoException( - 'Exception during route redirect: $error', - ); - return _errorRouteMatchList( - prevMatchList.uri, - goException, - extra: prevMatchList.extra, - ); - }); - } catch (exception) { - final GoException goException = - exception is GoException - ? exception - : GoException('Exception during route redirect: $exception'); - return _errorRouteMatchList( - prevMatchList.uri, - goException, - extra: prevMatchList.extra, - ); + if (routeLevelRedirectResult is String?) { + return processRouteLevelRedirect(routeLevelRedirectResult); } + return routeLevelRedirectResult + .then(processRouteLevelRedirect) + .catchError((Object error) { + final GoException goException = + error is GoException + ? error + : GoException('Exception during route redirect: $error'); + return _errorRouteMatchList( + prevMatchList.uri, + goException, + extra: prevMatchList.extra, + ); + }); + } catch (exception) { + final GoException goException = + exception is GoException + ? exception + : GoException('Exception during route redirect: $exception'); + return _errorRouteMatchList( + prevMatchList.uri, + goException, + extra: prevMatchList.extra, + ); } + } - redirectHistory.add(prevMatchList); - // Check for top-level redirect - final FutureOr topRedirectResult = _runInRouterZone(() { + if (prevMatchListFuture is RouteMatchList) { + return processRedirect(prevMatchListFuture); + } + return prevMatchListFuture.then(processRedirect); + } + + /// 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. + /// + /// 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; + } + + try { + final FutureOr res = _runInRouterZone(() { return _routingConfig.value.redirect( context, buildTopLevelGoRouterState(prevMatchList), ); }); - - if (topRedirectResult is String?) { - return processTopLevelRedirect(topRedirectResult); + if (res is String?) { + return done(res); } - return topRedirectResult.then(processTopLevelRedirect); - } - - if (prevMatchListFuture is RouteMatchList) { - return processRedirect(prevMatchListFuture); + return res.then(done).catchError((Object error) { + final GoException goException = + error is GoException + ? error + : GoException('Exception during redirect: $error'); + return _errorRouteMatchList( + prevMatchList.uri, + goException, + extra: prevMatchList.extra, + ); + }); + } catch (exception) { + final GoException goException = + exception is GoException + ? exception + : GoException('Exception during redirect: $exception'); + return _errorRouteMatchList( + prevMatchList.uri, + goException, + extra: prevMatchList.extra, + ); } - return prevMatchListFuture.then(processRedirect); } FutureOr _getRouteLevelRedirect( @@ -548,8 +596,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; } catch (exception) { final GoException goException = @@ -564,17 +618,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 b11edf116b8..3824490c1e8 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/on_enter.dart b/packages/go_router/lib/src/on_enter.dart new file mode 100644 index 00000000000..329f587a07d --- /dev/null +++ b/packages/go_router/lib/src/on_enter.dart @@ -0,0 +1,63 @@ +// 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. +/// 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}); + + /// 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. +/// +/// 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 with an optional [then] callback executed after + /// navigation completes. + const Allow({super.then}); +} + +/// 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')`). +/// +/// 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] that stops navigation without running a follow-up + /// 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 020a5bbb657..94ed9cb1d61 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -13,12 +13,13 @@ import 'information_provider.dart'; import 'logging.dart'; import 'match.dart'; import 'misc/errors.dart'; +import 'on_enter.dart'; import 'router.dart'; +import 'state.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]. @@ -28,116 +29,249 @@ 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 performs redirection using [RouteRedirector]. +/// +/// 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({ required this.configuration, + required GoRouter router, required this.onParserException, - }) : _routeMatchListCodec = RouteMatchListCodec(configuration); + }) : _routeMatchListCodec = RouteMatchListCodec(configuration), + _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 Random _random = Random(); + /// Stores the last successful match list to enable "stay" on the same route. + RouteMatchList? _lastMatchList; - /// The future of current route parsing. - /// - /// This is used for testing asynchronous redirection. + /// Instance of [_OnEnterHandler] to process top-level onEnter logic. + final _OnEnterHandler _onEnterHandler; + + /// The future of current route parsing (used for testing asynchronous redirection). @visibleForTesting Future? debugParserFuture; - /// Called by the [Router]. The + final Random _random = Random(); + @override Future parseRouteInformationWithDependencies( RouteInformation routeInformation, BuildContext context, ) { - assert(routeInformation.state != null); - final Object state = routeInformation.state!; - - 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 RouteMatchList matchList = _routeMatchListCodec.decode( - state as Map, + // 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(); + 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, ); - 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); - } - return value; - }); + infoState = RouteInformationState.restore(base: decoded); + incomingUri = decoded.uri; + } else { + infoState = raw; + incomingUri = routeInformation.uri; } - 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, + // Normalize once so downstream steps can assume the URI is canonical. + effectiveRoute = RouteInformation( + uri: RouteConfiguration.normalizeUri(incomingUri), + state: infoState, ); - if (initialMatches.isError) { - log('No initial matches: ${routeInformation.uri.path}'); - } - return debugParserFuture = _redirect( - context, - initialMatches, - ).then((RouteMatchList matchList) { - 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); - } + // 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: effectiveRoute, + infoState: infoState, + onCanEnter: () { + // Compose legacy top-level redirect here (one shared cycle/history). + final RouteMatchList initialMatches = configuration.findMatch( + effectiveRoute.uri, + extra: infoState.extra, + ); + final List redirectHistory = []; + + final FutureOr afterLegacy = configuration + .applyTopLegacyRedirect( + context, + initialMatches, + redirectHistory: redirectHistory, + ); - 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}', + if (afterLegacy is RouteMatchList) { + return _navigate( + effectiveRoute, + context, + infoState, + startingMatches: afterLegacy, + preSharedHistory: redirectHistory, ); } - return true; - }()); - return _updateRouteMatchList( - matchList, - baseRouteMatchList: state.baseRouteMatchList, - completer: state.completer, - type: state.type, - ); - }); + 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, + infoState, + startingMatches: ml, + preSharedHistory: redirectHistory, + ); + }); + }, + onCanNotEnter: () { + // If blocked, "stay" on last successful match if available. + if (_lastMatchList != null) { + return SynchronousFuture(_lastMatchList!); + } + + // 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, + ); + final RouteMatchList resolved = + onParserException != null + ? onParserException!(context, blocked) + : blocked; + return SynchronousFuture(resolved); + }, + ); + } + + /// 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( + RouteInformation routeInformation, + BuildContext context, + RouteInfoState infoState, { + FutureOr? startingMatches, + List? preSharedHistory, + }) { + // 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(routeInformation.uri, extra: infoState.extra); + + // History may be shared with the legacy step done in onEnter. + final List redirectHistory = + preSharedHistory ?? []; + + FutureOr afterRouteLevel(FutureOr base) { + if (base is RouteMatchList) { + return configuration.redirect( + context, + base, + redirectHistory: redirectHistory, + ); + } + return base.then((RouteMatchList ml) { + if (!context.mounted) { + return 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); + + return debugParserFuture = (redirected is RouteMatchList + ? SynchronousFuture(redirected) + : redirected) + .then((RouteMatchList matchList) { + if (matchList.isError && onParserException != null) { + if (!context.mounted) { + return matchList; + } + 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, + ); + + // Cache the successful match list. + _lastMatchList = updated; + return updated; + }); } @override Future parseRouteInformation( RouteInformation routeInformation, ) { + // Not used in go_router; instruct users to use parseRouteInformationWithDependencies. 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) { @@ -148,7 +282,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; @@ -156,7 +290,6 @@ class GoRouteInformationParser extends RouteInformationParser { break; } } - if (route case final ImperativeRouteMatch safeRoute) { location = safeRoute.matches.uri.toString(); } @@ -167,53 +300,7 @@ class GoRouteInformationParser extends RouteInformationParser { ); } - Future _redirect( - BuildContext context, - RouteMatchList routeMatch, - ) { - try { - final FutureOr redirectedFuture = configuration.redirect( - context, - routeMatch, - redirectHistory: [], - ); - if (redirectedFuture is RouteMatchList) { - return SynchronousFuture(redirectedFuture); - } - return redirectedFuture.catchError((Object error) { - // Convert any exception during redirect to a GoException - final GoException goException = - error is GoException - ? error - : GoException('Exception during redirect: $error'); - // Return an error match list instead of throwing - return RouteMatchList( - matches: const [], - extra: routeMatch.extra, - error: goException, - uri: routeMatch.uri, - pathParameters: const {}, - ); - }); - } catch (exception) { - // Convert any exception during redirect to a GoException - final GoException goException = - exception is GoException - ? exception - : GoException('Exception during redirect: $exception'); - // Return an error match list instead of throwing - return SynchronousFuture( - RouteMatchList( - matches: const [], - extra: routeMatch.extra, - error: goException, - uri: routeMatch.uri, - pathParameters: const {}, - ), - ); - } - } - + /// Updates the route match list based on the navigation type (push, replace, etc.). RouteMatchList _updateRouteMatchList( RouteMatchList newMatchList, { required RouteMatchList? baseRouteMatchList, @@ -258,13 +345,16 @@ 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, use the new one; otherwise, keep the old. + if (baseRouteMatchList!.uri.toString() != newMatchList.uri.toString()) { + return newMatchList; + } else { + return baseRouteMatchList; + } } } + /// Returns a unique [ValueKey] for a new route. ValueKey _getUniqueValueKey() { return ValueKey( String.fromCharCodes( @@ -273,3 +363,307 @@ 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 RouteInfoState infoState, + 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 guard, allow navigation immediately. + if (topOnEnter == null) { + return onCanEnter(); + } + + // Check if the redirection history exceeds the configured limit. + // `routeInformation` has already been normalized by the parser entrypoint. + 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 normalized 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 { + 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, + error is GoException ? error : GoException(error.toString()), + extra: infoState.extra, + ); + + _resetRedirectionHistory(); + + 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. + return onEnterResultFuture.then( + (OnEnterResult result) async { + RouteMatchList matchList; + final OnEnterThenCallback? callback = result.then; + + if (result is Allow) { + 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}', + ); + matchList = await onCanNotEnter(); + + // 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. + if (result.isStop) { + _resetRedirectionHistory(); + } + // For chaining blocks (with then), keep history to detect loops. + } + + if (callback != null) { + 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; + }, + 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()), + extra: infoState.extra, + ); + + if (_onParserException != null && context.mounted) { + return _onParserException(context, errorMatchList); + } + return 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, + RouteInfoState 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(); + if (_onParserException != null && context.mounted) { + return _onParserException(context, errorMatchList); + } + return 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 cd76b594c39..b0feb8f966e 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -15,6 +15,7 @@ import 'logging.dart'; import 'match.dart'; import 'misc/constants.dart'; import 'misc/inherited_router.dart'; +import 'on_enter.dart'; import 'parser.dart'; import 'route.dart'; import 'state.dart'; @@ -25,6 +26,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 @@ -39,6 +54,7 @@ class RoutingConfig { /// The [routes] must not be empty. const RoutingConfig({ required this.routes, + this.onEnter, this.redirect = _defaultRedirect, this.redirectLimit = 5, }); @@ -64,13 +80,45 @@ class RoutingConfig { /// implemented), a re-evaluation will be triggered when the [InheritedWidget] /// changes. /// - /// See [GoRouter]. + /// 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. /// /// See [GoRouter]. 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. 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 [Block], GoRouter + /// will stay on the current route or redirect to the initial route. + /// + /// Example: + /// ```dart + /// final GoRouter router = GoRouter( + /// routes: [...], + /// onEnter: (BuildContext context, GoRouterState current, + /// GoRouterState next, GoRouter router) async { + /// if (next.uri.path == '/login' && isUserLoggedIn()) { + /// 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 const Allow(); // Allow navigation + /// }, + /// ); + /// ``` + final OnEnter? onEnter; } /// The route configuration for the app. @@ -82,6 +130,13 @@ 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. +/// Order of operations: +/// 1) `onEnter` (your guard) - can block navigation +/// 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. /// Alternatively, you can specify a redirect for an individual route using /// [GoRoute.redirect]. If [BuildContext.dependOnInheritedWidgetOfExactType] is @@ -122,13 +177,14 @@ 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, GoRouterWidgetBuilder? errorBuilder, GoRouterRedirect? redirect, - Listenable? refreshListenable, int redirectLimit = 5, + Listenable? refreshListenable, bool routerNeglect = false, String? initialLocation, bool overridePlatformDefaultLocation = false, @@ -144,6 +200,7 @@ class GoRouter implements RouterConfig { RoutingConfig( routes: routes, redirect: redirect ?? RoutingConfig._defaultRedirect, + onEnter: onEnter, redirectLimit: redirectLimit, ), ), @@ -234,6 +291,7 @@ class GoRouter implements RouterConfig { routeInformationParser = GoRouteInformationParser( onParserException: parserExceptionHandler, configuration: configuration, + router: this, ); routeInformationProvider = GoRouteInformationProvider( @@ -378,7 +436,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, @@ -595,6 +653,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/pubspec.yaml b/packages/go_router/pubspec.yaml index b474000e38b..ee96525f8fb 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: 16.2.5 +version: 16.3.0 repository: https://github.com/flutter/packages/tree/main/packages/go_router issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router%22 diff --git a/packages/go_router/test/on_enter_test.dart b/packages/go_router/test/on_enter_test.dart new file mode 100644 index 00000000000..58f43d3a690 --- /dev/null +++ b/packages/go_router/test/on_enter_test.dart @@ -0,0 +1,1536 @@ +// 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. + +// 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 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(); + + 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') + ? const Block.stop() + : 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(); + + 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') + ? const Block.stop() + : const Allow(); + }, + 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') { + return Block.then(() => goRouter.push('/recursive')); + } + 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(); + + 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 const Allow(); + } + if (await isAuthenticated()) { + return const Allow(); + } + return Block.then(() => router.go('/sign-in')); + }, + 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') { + return Block.then( + () => goRouter.goNamed( + 'login-page', + queryParameters: {'from': next.uri.toString()}, + ), + ); + } + return const Allow(); + }, + 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 const Allow(); + } + if (await isAuthenticated()) { + return const Allow(); + } + await router.push('/sign-in').then((bool? isLoggedIn) { + if (isLoggedIn ?? false) { + router.go(next.uri.toString()); + } + }); + + return const Block.stop(); + }, + 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 const Block.stop(); + } + return const Allow(); + }, + 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 const Block.stop(); + } + return const Allow(); + }, + 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( + '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(), + )); + + final bool isNavigatingToProtected = next.uri.path == '/protected'; + + // Allow navigation if not going to the protected route + if (!isNavigatingToProtected) { + 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( + '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.stop(); + }, + 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', + )), + ]), + ), + ); + + // 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(); + + // 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; + + // 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); + + // Close the stream controller + 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'); + // 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 + if (targetPath == '/step-one') { + navigationComplete.complete(); + } + + return const Allow(); + }, + 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 const Allow(); + } + // 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); + }); + + 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.stop(); + } + 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.stop(); + } + 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')); + }); + + testWidgets('pop does not call onEnter but restore does', ( + 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(); + + // 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.stop(); + } + 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( + '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.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(); + + 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.stop(); + } + 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'); + }); + + 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']), + ); + }); + }); +}