Skip to content

[go_router] Fix replace routes hanging the originating completer #9332

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
18 changes: 12 additions & 6 deletions packages/go_router/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## 15.3.0

- Resolves an issue where `replace` and `pushReplacement` caused the originating
route's completer to hang by adding `mapReplacementResult` to
`RouteInformationState` and `ImperativeRouteMatch`.
[flutter#141251](https://github.com/flutter/flutter/issues/141251)

## 15.2.3

- Updates Type-safe routes topic documentation to use the mixin from `go_router_builder` 3.0.0.
Expand All @@ -8,16 +15,16 @@

## 15.2.1

* Fixes Popping state and re-rendering scaffold at the same time doesn't update the URL on web.
- Fixes Popping state and re-rendering scaffold at the same time doesn't update the URL on web.

## 15.2.0

* `GoRouteData` now defines `.location`, `.go(context)`, `.push(context)`, `.pushReplacement(context)`, and `replace(context)` to be used for [Type-safe routing](https://pub.dev/documentation/go_router/latest/topics/Type-safe%20routes-topic.html). **Requires go_router_builder >= 3.0.0**.
- `GoRouteData` now defines `.location`, `.go(context)`, `.push(context)`, `.pushReplacement(context)`, and `replace(context)` to be used for [Type-safe routing](https://pub.dev/documentation/go_router/latest/topics/Type-safe%20routes-topic.html). **Requires go_router_builder >= 3.0.0**.

## 15.1.3

* Updates minimum supported SDK version to Flutter 3.27/Dart 3.6.
* Fixes typo in API docs.
- Updates minimum supported SDK version to Flutter 3.27/Dart 3.6.
- Fixes typo in API docs.

## 15.1.2

Expand All @@ -41,7 +48,7 @@
## 14.8.1

- Secured canPop method for the lack of matches in routerDelegate's configuration.

## 14.8.0

- Adds `preload` parameter to `StatefulShellBranchData.$branch`.
Expand Down Expand Up @@ -1205,4 +1212,3 @@
## 0.1.0

- squatting on the package name (I'm not too proud to admit it)

3 changes: 3 additions & 0 deletions packages/go_router/example/devtools_options.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
10 changes: 6 additions & 4 deletions packages/go_router/lib/src/configuration.dart
Original file line number Diff line number Diff line change
Expand Up @@ -319,10 +319,12 @@ class RouteConfiguration {
for (final ImperativeRouteMatch imperativeMatch
in matchList.matches.whereType<ImperativeRouteMatch>()) {
final ImperativeRouteMatch match = ImperativeRouteMatch(
pageKey: imperativeMatch.pageKey,
matches: findMatch(imperativeMatch.matches.uri,
extra: imperativeMatch.matches.extra),
completer: imperativeMatch.completer);
pageKey: imperativeMatch.pageKey,
matches: findMatch(imperativeMatch.matches.uri,
extra: imperativeMatch.matches.extra),
completer: imperativeMatch.completer,
pipeCompleter: imperativeMatch.pipeCompleter,
);
result = result.push(match);
}
return result;
Expand Down
98 changes: 91 additions & 7 deletions packages/go_router/lib/src/information_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ enum NavigatingType {
restore,
}

/// Maps the replacement route result to the correct generic type.
typedef MapRouteResultCallback<T extends Object?> = T Function(Object? result);

/// Pipe completer to the last route match completer.
typedef PipeRouteCompleterCallback = Completer<Next?>
Function<Next extends Object?>(Completer<Next?> next);

/// The data class to be stored in [RouteInformation.state] to be used by
/// [GoRouteInformationParser].
///
Expand All @@ -49,9 +56,16 @@ class RouteInformationState<T> {
this.completer,
this.baseRouteMatchList,
required this.type,
MapRouteResultCallback<T?>? mapReplacementResult,
}) : assert((type == NavigatingType.go || type == NavigatingType.restore) ==
(completer == null)),
assert((type != NavigatingType.go) == (baseRouteMatchList != null));
assert((type != NavigatingType.go) == (baseRouteMatchList != null)),
mapReplacementResult =
mapReplacementResult ?? _defaultMapReplacementResult;

static T? _defaultMapReplacementResult<T>(Object? result) {
return null;
}

/// The extra object used when navigating with [GoRouter].
final Object? extra;
Expand All @@ -63,13 +77,30 @@ class RouteInformationState<T> {
/// [NavigatingType.restore].
final Completer<T?>? completer;

/// Maps the replacement result to the appropriate type, to resolve the
/// [completer].
final MapRouteResultCallback<T?> mapReplacementResult;

/// The base route match list to push on top to.
///
/// This is only null if [type] is [NavigatingType.go].
final RouteMatchList? baseRouteMatchList;

/// The type of navigation.
final NavigatingType type;

/// Pipes the completer to the next completer in the chain.
Completer<Next?> pipeCompleter<Next extends Object?>(Completer<Next?> next) {
if (completer == null) {
return next;
}

return _PipeCompleter<T?, Next?>(
next: next,
current: completer!,
mapReplacementResult: mapReplacementResult,
);
}
}

/// The [RouteInformationProvider] created by go_router.
Expand Down Expand Up @@ -156,8 +187,12 @@ class GoRouteInformationProvider extends RouteInformationProvider
}

/// Pushes the `location` as a new route on top of `base`.
Future<T?> push<T>(String location,
{required RouteMatchList base, Object? extra}) {
Future<T?> push<T>(
String location, {
required RouteMatchList base,
Object? extra,
MapRouteResultCallback<T?>? mapReplacementResult,
}) {
final Completer<T?> completer = Completer<T?>();
_setValue(
location,
Expand All @@ -166,6 +201,7 @@ class GoRouteInformationProvider extends RouteInformationProvider
baseRouteMatchList: base,
completer: completer,
type: NavigatingType.push,
mapReplacementResult: mapReplacementResult,
),
);
return completer.future;
Expand Down Expand Up @@ -196,8 +232,12 @@ class GoRouteInformationProvider extends RouteInformationProvider

/// Removes the top-most route match from `base` and pushes the `location` as a
/// new route on top.
Future<T?> pushReplacement<T>(String location,
{required RouteMatchList base, Object? extra}) {
Future<T?> pushReplacement<T>(
String location, {
required RouteMatchList base,
Object? extra,
MapRouteResultCallback<T?>? mapReplacementResult,
Copy link
Contributor

@chunhtai chunhtai Jun 23, 2025

Choose a reason for hiding this comment

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

This feels weird, If we want to separate two use cases.

  1. resolve the result immediately in pushReplacement
  2. replace the future in pushReplacement.

I think we should make two different API, maybe a pushReplacement for (1) and a pushReplaceResultFuture for (2), and in (2) we should force the type to be the same as previously pushed route.

Copy link
Author

@nouvist nouvist Jun 23, 2025

Choose a reason for hiding this comment

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

case (2) that match its originating type is guaranteed. this implementation makes the caller of the originating route is the one who's responsible for replacing future. for example, A is the one that responsible for replacing C result to match with B result type when it's pushing B. that's why mapReplacementResult is required for each pushed new route, because the assumption is any route can be replaced.

Route A -- (pushing) --> Route B -- (replaced by) --> Route C

I do consider to make the replace and pushReplacement itself that responsible for replacing future. but I struggle to make it match the originating route type. I think it's similar to pop where we have no idea what type it should be.

I didn't consider case (1) before, it is indeed necessary to implement. but then, when routes can be resolve immediately when replaced, it needs value to return, and then again, we have no idea what type it should be, like pop is.

}) {
final Completer<T?> completer = Completer<T?>();
_setValue(
location,
Expand All @@ -206,14 +246,19 @@ class GoRouteInformationProvider extends RouteInformationProvider
baseRouteMatchList: base,
completer: completer,
type: NavigatingType.pushReplacement,
mapReplacementResult: mapReplacementResult,
),
);
return completer.future;
}

/// Replaces the top-most route match from `base` with the `location`.
Future<T?> replace<T>(String location,
{required RouteMatchList base, Object? extra}) {
Future<T?> replace<T>(
String location, {
required RouteMatchList base,
Object? extra,
MapRouteResultCallback<T?>? mapReplacementResult,
}) {
final Completer<T?> completer = Completer<T?>();
_setValue(
location,
Expand Down Expand Up @@ -290,3 +335,42 @@ class GoRouteInformationProvider extends RouteInformationProvider
return SynchronousFuture<bool>(true);
}
}

/// Ensures the replacement routes can resolve the originating route completer.
/// Mainly used by [RouteInformationState.pipeCompleter]
class _PipeCompleter<Current extends Object?, Next extends Object?>
implements Completer<Next> {
_PipeCompleter({
required Completer<Next> next,
required Completer<Current> current,
required MapRouteResultCallback<Current> mapReplacementResult,
}) : _next = next,
_current = current,
_mapReplacementResult = mapReplacementResult;

final Completer<Next> _next;
final Completer<Current> _current;
final MapRouteResultCallback<Current> _mapReplacementResult;

@override
void complete([FutureOr<Next>? value]) {
_next.complete(value);
if (!_current.isCompleted) {
_current.complete(_mapReplacementResult(value));
}
}

@override
void completeError(Object error, [StackTrace? stackTrace]) {
_next.completeError(error, stackTrace);
if (!_current.isCompleted) {
_current.completeError(error, stackTrace);
}
}

@override
Future<Next> get future => _next.future;

@override
bool get isCompleted => _next.isCompleted;
}
15 changes: 12 additions & 3 deletions packages/go_router/lib/src/match.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import 'package:logging/logging.dart';
import 'package:meta/meta.dart' as meta;

import 'configuration.dart';
import 'information_provider.dart';
import 'logging.dart';
import 'misc/errors.dart';
import 'path_utils.dart';
Expand Down Expand Up @@ -432,9 +433,12 @@ class ShellRouteMatch extends RouteMatchBase {
/// The route match that represent route pushed through [GoRouter.push].
class ImperativeRouteMatch extends RouteMatch {
/// Constructor for [ImperativeRouteMatch].
ImperativeRouteMatch(
{required super.pageKey, required this.matches, required this.completer})
: super(
ImperativeRouteMatch({
required super.pageKey,
required this.matches,
required this.completer,
required this.pipeCompleter,
}) : super(
route: _getsLastRouteFromMatches(matches),
matchedLocation: _getsMatchedLocationFromMatches(matches),
);
Expand All @@ -460,6 +464,9 @@ class ImperativeRouteMatch extends RouteMatch {
/// The completer for the future returned by [GoRouter.push].
final Completer<Object?> completer;

/// Pipes the completer to the next completer in the chain.
final PipeRouteCompleterCallback pipeCompleter;

/// Called when the corresponding [Route] associated with this route match is
/// completed.
void complete([dynamic value]) {
Expand Down Expand Up @@ -967,6 +974,8 @@ class _RouteMatchListDecoder
// https://github.com/flutter/flutter/issues/128122.
completer: Completer<Object?>(),
matches: imperativeMatchList,
// TODO(nouvist): Sorry, I don't know what convert does.
pipeCompleter: <Next>(Completer<Next?> next) => next,
);
matchList = matchList.push(imperativeMatch);
}
Expand Down
23 changes: 21 additions & 2 deletions packages/go_router/lib/src/parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ class GoRouteInformationParser extends RouteInformationParser<RouteMatchList> {
baseRouteMatchList: state.baseRouteMatchList,
completer: state.completer,
type: state.type,
pipeCompleter: state.pipeCompleter,
);
});
}
Expand Down Expand Up @@ -168,11 +169,24 @@ class GoRouteInformationParser extends RouteInformationParser<RouteMatchList> {
return redirectedFuture;
}

/// Ensures the replacement routes can resolve the originating route completer.
Completer<T?> _pipeCompleter<T extends Object?>(
Completer<T?> currentCompleter,
RouteMatch lastRouteMatch,
) {
if (lastRouteMatch is ImperativeRouteMatch) {
return lastRouteMatch.pipeCompleter(currentCompleter);
} else {
return currentCompleter;
}
}

RouteMatchList _updateRouteMatchList(
RouteMatchList newMatchList, {
required RouteMatchList? baseRouteMatchList,
required Completer<Object?>? completer,
required NavigatingType type,
required PipeRouteCompleterCallback pipeCompleter,
}) {
switch (type) {
case NavigatingType.push:
Expand All @@ -181,32 +195,37 @@ class GoRouteInformationParser extends RouteInformationParser<RouteMatchList> {
pageKey: _getUniqueValueKey(),
completer: completer!,
matches: newMatchList,
pipeCompleter: pipeCompleter,
),
);
case NavigatingType.pushReplacement:
final RouteMatch routeMatch = baseRouteMatchList!.last;
completer = _pipeCompleter(completer!, routeMatch);
baseRouteMatchList = baseRouteMatchList.remove(routeMatch);
if (baseRouteMatchList.isEmpty) {
return newMatchList;
}
return baseRouteMatchList.push(
ImperativeRouteMatch(
pageKey: _getUniqueValueKey(),
completer: completer!,
completer: completer,
matches: newMatchList,
pipeCompleter: pipeCompleter,
),
);
case NavigatingType.replace:
final RouteMatch routeMatch = baseRouteMatchList!.last;
completer = _pipeCompleter(completer!, routeMatch);
baseRouteMatchList = baseRouteMatchList.remove(routeMatch);
if (baseRouteMatchList.isEmpty) {
return newMatchList;
}
return baseRouteMatchList.push(
ImperativeRouteMatch(
pageKey: routeMatch.pageKey,
completer: completer!,
completer: completer,
matches: newMatchList,
pipeCompleter: pipeCompleter,
),
);
case NavigatingType.go:
Expand Down
Loading