Skip to content

[go_router] fix PopScope.onPopInvokedWithResult getting called twice when popping a route if GoRouteData.onExit is not null #8896

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

Closed
Closed
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
3f48e22
fix: call route.didPop(result) when onExit returns true
techouse Mar 23, 2025
f8d2118
test: add tests for PopScope onPopInvokedWithResult behavior
techouse Mar 23, 2025
e001872
chore: bump version to 14.8.2-dev in pubspec.yaml
techouse Mar 23, 2025
d3dfd24
chore: update CHANGELOG for version 14.8.2-dec with PopScope fix
techouse Mar 23, 2025
88d9739
chore: fix typo in changelog
techouse Mar 23, 2025
d5cb229
Merge branch 'main' into fix/double-call-onPopInvokedWithResult
techouse Mar 24, 2025
fcd8dfc
chore: fix version number
techouse Mar 26, 2025
9ecd5eb
Merge branch 'main' into fix/double-call-onPopInvokedWithResult
techouse Mar 26, 2025
ea27f87
Merge branch 'main' into fix/double-call-onPopInvokedWithResult
techouse Mar 27, 2025
46b9738
Merge branch 'main' into fix/double-call-onPopInvokedWithResult
techouse Mar 27, 2025
ec5184f
Merge branch 'main' into fix/double-call-onPopInvokedWithResult
techouse Mar 28, 2025
8a137b9
Merge branch 'main' into fix/double-call-onPopInvokedWithResult
techouse Mar 28, 2025
7f4defc
Merge branch 'main' into fix/double-call-onPopInvokedWithResult
techouse Mar 31, 2025
d273d97
Merge branch 'main' into fix/double-call-onPopInvokedWithResult
techouse Apr 1, 2025
9e23a5c
Merge branch 'main' into fix/double-call-onPopInvokedWithResult
techouse Apr 1, 2025
ec6137e
fix: update onExit callback to handle null values and improve type sa…
techouse Apr 1, 2025
6df5625
Merge branch 'main' into fix/double-call-onPopInvokedWithResult
techouse Apr 1, 2025
469c8cf
:recycle: convert SubRoute.onExit to a getter
techouse Apr 1, 2025
b3f27f8
Merge branch 'main' into fix/double-call-onPopInvokedWithResult
techouse Apr 2, 2025
5a8a741
Merge branch 'main' into fix/double-call-onPopInvokedWithResult
techouse Apr 2, 2025
6205101
Merge branch 'main' into fix/double-call-onPopInvokedWithResult
techouse Apr 3, 2025
57989d4
Merge branch 'main' into fix/double-call-onPopInvokedWithResult
techouse Apr 6, 2025
edc3b7c
Merge branch 'main' into fix/double-call-onPopInvokedWithResult
techouse Apr 10, 2025
564dd10
Merge branch 'main' into fix/double-call-onPopInvokedWithResult
techouse Apr 15, 2025
3a856a1
Merge remote-tracking branch 'origin/main' into fix/double-call-onPop…
techouse Apr 22, 2025
573eefe
Merge remote-tracking branch 'refs/remotes/origin/main' into fix/doub…
techouse May 6, 2025
a55947f
Merge branch 'main' into fix/double-call-onPopInvokedWithResult
techouse May 7, 2025
a0c1ae1
Merge branch 'main' into fix/double-call-onPopInvokedWithResult
techouse May 8, 2025
4a83178
fix: ensure non-null onExit callback usage to prevent potential null …
techouse May 8, 2025
6b6d08d
Merge remote-tracking branch 'origin/fix/double-call-onPopInvokedWith…
techouse May 8, 2025
15c58a9
Merge branch 'main' into fix/double-call-onPopInvokedWithResult
techouse May 8, 2025
9accb64
fix: update onExit to return a FutureOr<bool> and ensure proper callb…
techouse May 8, 2025
c3e6ace
chore: revert change
techouse May 8, 2025
0b3119f
chore: add copyright notice to on_exit_on_pop_invoked_with_result_tes…
techouse May 8, 2025
c4f47b7
fix: bump version to 15.1.2
techouse May 8, 2025
2dff8e8
test: move onPopInvokedWithResult tests to on_exit_test.dart
techouse May 8, 2025
bd46023
Merge branch 'main' into fix/double-call-onPopInvokedWithResult
techouse May 8, 2025
a1228df
test: simplify parameters in onPopInvokedWithResult and buildPage met…
techouse May 8, 2025
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
4 changes: 4 additions & 0 deletions packages/go_router/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 15.1.2

- Fixes `PopScope.onPopInvokedWithResult` getting called twice when popping a route if `GoRouteData.onExit` is not null

## 15.1.1

- Adds missing `caseSensitive` to `GoRouteData.$route`.
Expand Down
25 changes: 16 additions & 9 deletions packages/go_router/lib/src/delegate.dart
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,12 @@ class GoRouterDelegate extends RouterDelegate<RouteMatchList>
// Fallback to onExit if maybePop did not handle the pop
final GoRoute lastRoute = currentConfiguration.last.route;
if (lastRoute.onExit != null && navigatorKey.currentContext != null) {
return !(await lastRoute.onExit!(
navigatorKey.currentContext!,
currentConfiguration.last
.buildState(_configuration, currentConfiguration),
));
return !(await lastRoute.onExit!.call(
navigatorKey.currentContext!,
currentConfiguration.last
.buildState(_configuration, currentConfiguration),
) ??
true); // @TODO(techouse) not sure if returning true by default is correct
}

return false;
Expand Down Expand Up @@ -146,11 +147,14 @@ class GoRouterDelegate extends RouterDelegate<RouteMatchList>
// a microtask in case the onExit callback want to launch dialog or other
// navigator operations.
scheduleMicrotask(() async {
final bool onExitResult = await routeBase.onExit!(
final bool? onExitResult = await routeBase.onExit!.call(
navigatorKey.currentContext!,
match.buildState(_configuration, currentConfiguration),
);
if (onExitResult) {
if (onExitResult == null) {
route.didPop(result);
}
if (onExitResult ?? true) {
_completeRouteMatch(result, match);
}
});
Expand Down Expand Up @@ -298,14 +302,17 @@ class GoRouterDelegate extends RouterDelegate<RouteMatchList>
return SynchronousFuture<bool>(false);
}

final FutureOr<bool> exitFuture = goRoute.onExit!(
final FutureOr<bool>? exitFuture = goRoute.onExit?.call(
context,
match.buildState(_configuration, currentConfiguration),
);
if (exitFuture is bool) {
return handleOnExitResult(exitFuture);
}
return exitFuture.then<bool>(handleOnExitResult);
return exitFuture?.then<bool>(handleOnExitResult) ??
SynchronousFuture<bool>(
true, // @TODO(techouse) not sure if returning true by default is correct
);
}

Future<void> _setCurrentConfiguration(RouteMatchList configuration) {
Expand Down
3 changes: 2 additions & 1 deletion packages/go_router/lib/src/route.dart
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ typedef NavigatorBuilder = Widget Function(
///
/// If the return value is true or the future resolve to true, the route will
/// exit as usual. Otherwise, the operation will abort.
typedef ExitCallback = FutureOr<bool> Function(
typedef ExitCallback = FutureOr<bool>? Function(
BuildContext context, GoRouterState state);

/// The base class for [GoRoute] and [ShellRoute].
Expand Down Expand Up @@ -1453,6 +1453,7 @@ class _RestorableRouteMatchList extends RestorableProperty<RouteMatchList> {

RouteMatchList get value => _value;
RouteMatchList _value = RouteMatchList.empty;

set value(RouteMatchList newValue) {
if (newValue != _value) {
_value = newValue;
Expand Down
6 changes: 3 additions & 3 deletions packages/go_router/lib/src/route_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ abstract class GoRouteData extends RouteData {
/// Called when this route is removed from GoRouter's route history.
///
/// Corresponds to [GoRoute.onExit].
FutureOr<bool> onExit(BuildContext context, GoRouterState state) => true;
ExitCallback? get onExit => null;

/// A helper function used by generated code.
///
Expand Down Expand Up @@ -112,8 +112,8 @@ abstract class GoRouteData extends RouteData {
FutureOr<String?> redirect(BuildContext context, GoRouterState state) =>
factoryImpl(state).redirect(context, state);

FutureOr<bool> onExit(BuildContext context, GoRouterState state) =>
factoryImpl(state).onExit(context, state);
FutureOr<bool>? onExit(BuildContext context, GoRouterState state) =>
factoryImpl(state).onExit?.call(context, state);

return GoRoute(
path: path,
Expand Down
137 changes: 137 additions & 0 deletions packages/go_router/test/on_exit_on_pop_invoked_with_result_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';

void main() {
testWidgets(
'PopScope onPopInvokedWithResult should be called only once',
(WidgetTester tester) async {
int counter = 0;

final GoRouter goRouter = GoRouter(
initialLocation: '/',
routes: <GoRoute>[
GoRoute(path: '/', builder: (_, __) => const DummyStatefulWidget()),
GoRoute(
path: '/page-1',
builder: (_, __) => PopScope(
onPopInvokedWithResult: (bool didPop, __) {
if (didPop) {
counter++;
return;
}
},
child: const Text('Page 1'),
),
),
],
);

addTearDown(goRouter.dispose);

await tester.pumpWidget(MaterialApp.router(
routeInformationProvider: goRouter.routeInformationProvider,
routeInformationParser: goRouter.routeInformationParser,
routerDelegate: goRouter.routerDelegate,
));

goRouter.push('/page-1');

await tester.pumpAndSettle();

expect(find.text('Page 1'), findsOneWidget);
expect(
goRouter.routerDelegate.currentConfiguration.matches.length,
equals(2),
);
expect(goRouter.routerDelegate.canPop(), true);

goRouter.routerDelegate.pop();

await tester.pumpAndSettle();

expect(counter, equals(1));
},
);

testWidgets(
r'PopScope onPopInvokedWithResult should be called only once with GoRouteData.$route',
(WidgetTester tester) async {
int counter = 0;

final GoRouter goRouter = GoRouter(
initialLocation: '/',
routes: <GoRoute>[
GoRoute(path: '/', builder: (_, __) => const DummyStatefulWidget()),
GoRouteData.$route(
path: '/page-1',
factory: (GoRouterState state) => _Page1(
onPop: () {
counter++;
},
),
),
],
);

addTearDown(goRouter.dispose);

await tester.pumpWidget(MaterialApp.router(
routeInformationProvider: goRouter.routeInformationProvider,
routeInformationParser: goRouter.routeInformationParser,
routerDelegate: goRouter.routerDelegate,
));

goRouter.push('/page-1');

await tester.pumpAndSettle();

expect(find.text('Page 1'), findsOneWidget);
expect(
goRouter.routerDelegate.currentConfiguration.matches.length,
equals(2),
);
expect(goRouter.routerDelegate.canPop(), true);

goRouter.routerDelegate.pop();

await tester.pumpAndSettle();

expect(counter, equals(1));
},
);
}

class _Page1 extends GoRouteData {
const _Page1({
required this.onPop,
});

final VoidCallback onPop;

@override
Page<void> buildPage(BuildContext context, GoRouterState state) =>
MaterialPage<void>(
child: PopScope(
onPopInvokedWithResult: (bool didPop, __) {
if (didPop) {
onPop();
return;
}
},
child: const Text('Page 1'),
),
);
}

class DummyStatefulWidget extends StatefulWidget {
const DummyStatefulWidget({super.key});

@override
State<DummyStatefulWidget> createState() => _DummyStatefulWidgetState();
}

class _DummyStatefulWidgetState extends State<DummyStatefulWidget> {
@override
Widget build(BuildContext context) => Container();
}
36 changes: 18 additions & 18 deletions packages/go_router_builder/example/lib/on_exit_example.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,25 +40,25 @@ class SubRoute extends GoRouteData {
const SubRoute();

@override
Future<bool> onExit(BuildContext context, GoRouterState state) async {
final bool? confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
content: const Text('Are you sure to leave this page?'),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
ExitCallback get onExit => (BuildContext context, GoRouterState state) async {
final bool? confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
content: const Text('Are you sure to leave this page?'),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Confirm'),
),
],
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Confirm'),
),
],
),
);
return confirmed ?? false;
}
);
return confirmed ?? false;
};

@override
Widget build(BuildContext context, GoRouterState state) => const SubScreen();
Expand Down