diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index af3840e6cf35..71fa8074b649 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,8 @@ +## NEXT + +- Adds route-level `onEnter` support to `GoRoute` for implementing navigation guards at individual route level. +- Adds `onEnter` method to `GoRouteData` base class for use with type-safe routing. + ## 17.0.1 - Fixes an issue where `onEnter` blocking causes navigation stack loss (stale state restoration). diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index eb7a43b4b9d2..acb7eb26a02e 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -12,6 +12,7 @@ import 'configuration.dart'; import 'information_provider.dart'; import 'logging.dart'; import 'match.dart'; +import 'route.dart'; import 'misc/errors.dart'; import 'on_enter.dart'; import 'router.dart'; @@ -189,6 +190,10 @@ class GoRouteInformationParser extends RouteInformationParser { ); } + // Route-level onEnter handling is implemented as an instance method + // below within the [_OnEnterHandler] class. It was moved there from + // this class to better encapsulate the stateful redirection logic. + /// Finds matching routes, processes redirects, and updates the route match /// list based on the navigation type. /// @@ -492,6 +497,8 @@ class _OnEnterHandler { extra: infoState.extra, ); + // Reset redirection history before handling the error to ensure the + // next navigation starts with a clean slate. _resetRedirectionHistory(); final bool canHandleException = @@ -510,8 +517,28 @@ class _OnEnterHandler { final OnEnterThenCallback? callback = result.then; if (result is Allow) { - matchList = await onCanEnter(); - _resetRedirectionHistory(); // reset after committed navigation + // Top-level allowed: run route-level onEnter callbacks (if any). + // If any route-level onEnter blocks, handle as blocked navigation. + final RouteMatchList? routeLevelBlocked = + await _runRouteLevelOnEnterIfNeeded( + context: context, + routeInformation: routeInformation, + infoState: infoState, + onCanEnter: onCanEnter, + onCanNotEnter: onCanNotEnter, + ); + + if (routeLevelBlocked != null) { + // Navigation was blocked at the route level; reset history before + // returning the "blocked" match list. + matchList = routeLevelBlocked; + _resetRedirectionHistory(); + } else { + matchList = await onCanEnter(); + // Reset history after a successful navigation to clear state for + // the next navigation request. + _resetRedirectionHistory(); + } } else { // Block: check if this is a hard stop or chaining block log( @@ -523,6 +550,8 @@ class _OnEnterHandler { // 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) { + // Reset history on a hard stop (Block.stop) to ensure the next + // navigation attempt starts fresh. _resetRedirectionHistory(); } // For chaining blocks (with then), keep history to detect loops. @@ -548,7 +577,8 @@ class _OnEnterHandler { return matchList; }, onError: (Object error, StackTrace stackTrace) { - // Reset history on error to prevent stale state + // Reset history on error to prevent stale state from affecting + // subsequent navigation cycles. _resetRedirectionHistory(); final RouteMatchList errorMatchList = _errorRouteMatchList( @@ -565,6 +595,111 @@ class _OnEnterHandler { ); } + /// Runs route-level `onEnter` callbacks for the incoming matches, if any. + /// + /// Returns a [RouteMatchList] when a route-level callback blocks navigation + /// (the result of calling [onCanNotEnter]). Returns `null` if all + /// route-level callbacks allowed navigation. + Future _runRouteLevelOnEnterIfNeeded({ + required BuildContext context, + required RouteInformation routeInformation, + required RouteInfoState infoState, + required NavigationCallback onCanEnter, + required NavigationCallback onCanNotEnter, + }) async { + // Find route matches for the normalized URI. + final RouteMatchList incomingMatches = _configuration.findMatch( + routeInformation.uri, + extra: infoState.extra, + ); + + // Build the next and current states used for callbacks. + final GoRouterState nextState = _buildTopLevelGoRouterState( + incomingMatches, + ); + final RouteMatchList currentMatchList = + _router.routerDelegate.currentConfiguration; + final GoRouterState currentState = currentMatchList.isNotEmpty + ? _buildTopLevelGoRouterState(currentMatchList) + : nextState; + + // Collect matches that have route-level onEnter defined. + final List routeMatches = []; + incomingMatches.visitRouteMatches((RouteMatchBase match) { + final RouteBase r = match.route; + if (r is GoRoute && r.onEnter != null) { + routeMatches.add(match); + } + return true; + }); + + // Iterate in the same visitation order and invoke onEnter sequentially. + for (final RouteMatchBase match in routeMatches) { + if (!context.mounted) { + // If context is unmounted, abort the entire navigation. + return _errorRouteMatchList( + routeInformation.uri, + GoException( + 'Navigation aborted because the router context was disposed.', + ), + extra: infoState.extra, + ); + } + final RouteBase routeBase = match.route; + if (routeBase is! GoRoute || routeBase.onEnter == null) { + continue; + } + + try { + final FutureOr result = routeBase.onEnter!( + context, + currentState, + nextState, + _router, + ); + final OnEnterResult onEnterResult = result is OnEnterResult + ? result + : await result; + + if (onEnterResult is Block) { + // When a route-level onEnter blocks, run onCanNotEnter to get final match list. + final OnEnterThenCallback? thenCallback = onEnterResult.then; + final RouteMatchList matchList = await onCanNotEnter(); + + if (thenCallback != null) { + try { + await Future.sync(thenCallback); + } catch (error, stack) { + log('Error in then callback: $error'); + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stack, + library: 'go_router', + context: ErrorDescription('while executing then callback'), + ), + ); + } + } + + return matchList; + } + } catch (error) { + 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; + } + } + + return null; + } + /// Builds a [GoRouterState] based on the given [matchList]. /// /// This method derives the effective URI, full path, path parameters, and extra data from diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index cf1a7e240827..fda21d443704 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -11,8 +11,10 @@ import 'package:flutter/widgets.dart'; // https://github.com/flutter/flutter/issues/171410 import 'package:meta/meta.dart' as meta; +import '../go_router.dart'; import 'configuration.dart'; import 'match.dart'; +import 'on_enter.dart'; import 'path_utils.dart'; import 'router.dart'; import 'state.dart'; @@ -70,6 +72,7 @@ typedef NavigatorBuilder = typedef ExitCallback = FutureOr Function(BuildContext context, GoRouterState state); + /// The base class for [GoRoute] and [ShellRoute]. /// /// Routes are defined in a tree such that parent routes must match the @@ -280,6 +283,7 @@ class GoRoute extends RouteBase { super.parentNavigatorKey, super.redirect, this.onExit, + this.onEnter, this.caseSensitive = true, super.routes = const [], }) : assert(path.isNotEmpty, 'GoRoute path cannot be empty'), @@ -449,6 +453,28 @@ class GoRoute extends RouteBase { /// ``` final ExitCallback? onExit; + /// Called before this route is entered. + /// + /// This callback can be used to implement navigation guards specific to this + /// route. Return [Allow] to proceed with navigation, or [Block] to prevent it. + /// + /// Example: + /// ```dart + /// GoRoute( + /// path: '/protected', + /// builder: (BuildContext context, GoRouterState state) => + /// ProtectedScreen(), + /// onEnter: (BuildContext context, GoRouterState current, + /// GoRouterState next, GoRouter router) { + /// if (!AuthService.isAuthenticated) { + /// return Block.then(() => router.go('/login')); + /// } + /// return const Allow(); + /// }, + /// ), + /// ``` + final OnEnter? onEnter; + /// Determines whether the route matching is case sensitive. /// /// When `true`, the path must match the specified case. For example, diff --git a/packages/go_router/lib/src/route_data.dart b/packages/go_router/lib/src/route_data.dart index dfbd9cced43b..81c1941a5be5 100644 --- a/packages/go_router/lib/src/route_data.dart +++ b/packages/go_router/lib/src/route_data.dart @@ -9,7 +9,9 @@ import 'package:meta/meta.dart'; import 'package:meta/meta_meta.dart'; import 'configuration.dart'; +import 'on_enter.dart'; import 'route.dart'; +import 'router.dart'; import 'state.dart'; /// Baseclass for supporting @@ -62,6 +64,19 @@ abstract class _GoRouteDataBase extends RouteData { /// Corresponds to [GoRoute.onExit]. FutureOr onExit(BuildContext context, GoRouterState state) => true; + /// Called before this route is entered. + /// + /// This method can be overridden to implement route-level navigation guards. + /// Return [Allow] to proceed with navigation, or [Block] to prevent it. + /// + /// Corresponds to [GoRoute.onEnter]. + FutureOr onEnter( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter router, + ) => const Allow(); + /// The error thrown when a user-facing method is not implemented by the /// generated code. static UnimplementedError get shouldBeGeneratedError => UnimplementedError( @@ -91,12 +106,14 @@ class _GoRouteParameters { required this.pageBuilder, required this.redirect, required this.onExit, + required this.onEnter, }); final GoRouterWidgetBuilder builder; final GoRouterPageBuilder pageBuilder; final GoRouterRedirect redirect; final ExitCallback onExit; + final OnEnter onEnter; } /// Helper to create [GoRoute] parameters from a factory function and an Expando. @@ -125,6 +142,13 @@ _GoRouteParameters _createGoRouteParameters({ factoryImpl(state).redirect(context, state), onExit: (BuildContext context, GoRouterState state) => factoryImpl(state).onExit(context, state), + onEnter: + ( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter router, + ) => factoryImpl(next).onEnter(context, current, next, router), ); } @@ -172,6 +196,7 @@ abstract class GoRouteData extends _GoRouteDataBase { routes: routes, parentNavigatorKey: parentNavigatorKey, onExit: params.onExit, + onEnter: params.onEnter, ); } @@ -242,6 +267,7 @@ abstract class RelativeGoRouteData extends _GoRouteDataBase { routes: routes, parentNavigatorKey: parentNavigatorKey, onExit: params.onExit, + onEnter: params.onEnter, ); } diff --git a/packages/go_router_builder/CHANGELOG.md b/packages/go_router_builder/CHANGELOG.md index f20108c17fa6..173f62d1d33e 100644 --- a/packages/go_router_builder/CHANGELOG.md +++ b/packages/go_router_builder/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Adds support for `onEnter` method in `GoRouteData` classes to implement navigation guards. + ## 4.1.3 * Requires `analyzer` 8.2 or higher, to avoid experimental APIs. diff --git a/packages/go_router_builder/test_inputs/on_enter.dart b/packages/go_router_builder/test_inputs/on_enter.dart new file mode 100644 index 000000000000..afabdb6f6b4e --- /dev/null +++ b/packages/go_router_builder/test_inputs/on_enter.dart @@ -0,0 +1,36 @@ +// 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'; + +mixin $OnEnterRoute {} + +@TypedGoRoute(path: '/on-enter') +class OnEnterRoute extends GoRouteData with $OnEnterRoute { + const OnEnterRoute(); + + @override + FutureOr onEnter( + BuildContext context, + GoRouterState current, + GoRouterState next, + GoRouter router, + ) { + // Example navigation guard + if (someCondition) { + return const Block.stop(); + } + return const Allow(); + } + + @override + Widget build(BuildContext context, GoRouterState state) { + return const Placeholder(); + } +} + +const bool someCondition = false; diff --git a/packages/go_router_builder/test_inputs/on_enter.dart.expect b/packages/go_router_builder/test_inputs/on_enter.dart.expect new file mode 100644 index 000000000000..732042fac667 --- /dev/null +++ b/packages/go_router_builder/test_inputs/on_enter.dart.expect @@ -0,0 +1,22 @@ +RouteBase get $onEnterRoute => + GoRouteData.$route(path: '/on-enter', factory: $OnEnterRoute._fromState); + +mixin $OnEnterRoute on GoRouteData { + static OnEnterRoute _fromState(GoRouterState state) => const OnEnterRoute(); + + @override + String get location => GoRouteData.$location('/on-enter'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +}