-
Notifications
You must be signed in to change notification settings - Fork 3.6k
go_router: add onEnter route callback #10898
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
05cfee3
b9401ec
0477040
c54cc6b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<RouteMatchList> { | |
| ); | ||
| } | ||
|
|
||
| // 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<RouteMatchList?> _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<RouteMatchBase> routeMatches = <RouteMatchBase>[]; | ||
| 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think if context is not mounted, we should just abort the entire navigation |
||
| // 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<OnEnterResult> 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<void>.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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The import |
||
| 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<bool> 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 <RouteBase>[], | ||
| }) : 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, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<OnEnterRoute>(path: '/on-enter') | ||
| class OnEnterRoute extends GoRouteData with $OnEnterRoute { | ||
| const OnEnterRoute(); | ||
|
|
||
| @override | ||
| FutureOr<OnEnterResult> 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; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<T?> push<T>(BuildContext context) => context.push<T>(location); | ||
|
|
||
| @override | ||
| void pushReplacement(BuildContext context) => | ||
| context.pushReplacement(location); | ||
|
|
||
| @override | ||
| void replace(BuildContext context) => context.replace(location); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why do we reset history here?