Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/go_router/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
141 changes: 138 additions & 3 deletions packages/go_router/lib/src/parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -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 =
Expand All @@ -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();
Copy link
Contributor

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?

} 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(
Expand All @@ -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.
Expand All @@ -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(
Expand All @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The 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
Expand Down
26 changes: 26 additions & 0 deletions packages/go_router/lib/src/route.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The import ../go_router.dart seems redundant here. Typically, go_router.dart re-exports its public APIs, and specific files like configuration.dart, match.dart, on_enter.dart, etc., are imported directly. Importing the top-level library might lead to unnecessary dependencies or potential circular imports if not carefully managed. Please consider removing this import if all necessary symbols are already imported from their specific files.

import 'configuration.dart';
import 'match.dart';
import 'on_enter.dart';
import 'path_utils.dart';
import 'router.dart';
import 'state.dart';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions packages/go_router/lib/src/route_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -62,6 +64,19 @@ abstract class _GoRouteDataBase extends RouteData {
/// Corresponds to [GoRoute.onExit].
FutureOr<bool> 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<OnEnterResult> 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(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -125,6 +142,13 @@ _GoRouteParameters _createGoRouteParameters<T extends _GoRouteDataBase>({
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),
);
}

Expand Down Expand Up @@ -172,6 +196,7 @@ abstract class GoRouteData extends _GoRouteDataBase {
routes: routes,
parentNavigatorKey: parentNavigatorKey,
onExit: params.onExit,
onEnter: params.onEnter,
);
}

Expand Down Expand Up @@ -242,6 +267,7 @@ abstract class RelativeGoRouteData extends _GoRouteDataBase {
routes: routes,
parentNavigatorKey: parentNavigatorKey,
onExit: params.onExit,
onEnter: params.onEnter,
);
}

Expand Down
4 changes: 4 additions & 0 deletions packages/go_router_builder/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
36 changes: 36 additions & 0 deletions packages/go_router_builder/test_inputs/on_enter.dart
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;
22 changes: 22 additions & 0 deletions packages/go_router_builder/test_inputs/on_enter.dart.expect
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);
}