Skip to content

[go_router] Make ImperativeRouteMatch.complete idempotent#11805

Open
ZephyrVentum wants to merge 1 commit into
flutter:mainfrom
ZephyrVentum:imperative-route-match-idempotent-complete
Open

[go_router] Make ImperativeRouteMatch.complete idempotent#11805
ZephyrVentum wants to merge 1 commit into
flutter:mainfrom
ZephyrVentum:imperative-route-match-idempotent-complete

Conversation

@ZephyrVentum
Copy link
Copy Markdown

Description

Adds an isCompleted guard inside ImperativeRouteMatch.complete so calling it a second time after the future has already resolved is silently ignored instead of throwing Bad state: Future already completed.

Root cause

GoRouteData ships a default FutureOr<bool> onExit(...) => true, and go_router_builder wires it into every generated $route factory. That makes routeBase.onExit != null for every route declared via @TypedGoRoute / GoRouteData, which forces every pop through the scheduleMicrotask path in GoRouterDelegate._handlePopPageWithRouteMatch:

scheduleMicrotask(() async {
  final bool onExitResult = await routeBase.onExit!(...);
  if (onExitResult) {
    _completeRouteMatch(result, match);
  }
});
return false;

Because the callback returns false, the Navigator does not mark the popped page as resolved synchronously — the page is only actually removed when the microtask eventually runs _completeRouteMatchcurrentConfiguration.remove(match)notifyListeners → Navigator rebuild without the page.

If the framework reaches _handlePopPageWithRouteMatch a second time for the same ImperativeRouteMatch before either microtask runs, both microtasks call _completeRouteMatch. The first one completes the Completer and removes the match; the second one calls completer.complete(value) against an already-completed Completer and throws.

Triggers observed in the wild include:

  • iOS interactive back-edge swipe gesture firing Navigator.onPopPage more than once during the commit/animation window for Page-based routes.
  • Chained context.pop(result) calls bubbling a value through nested modals.
  • Async dialog dismiss racing the route under the dialog popping itself.

Behavior change

User-visible behavior is unchanged either way — currentConfiguration is already updated and notifyListeners rebuilds the Navigator without the popped page before the second microtask fires. The error only manifests as a noisy throw in apps' global error handlers (PlatformDispatcher.onError / FlutterError.onError), typically forwarded to crash-reporting as fatal.

With this patch, the second complete call is a no-op, the original future value is preserved, and no exception is thrown.

Tests

packages/go_router/test/match_test.dart gains one regression test under the existing group('ImperativeRouteMatch'):

test('complete is idempotent — second call is a no-op', () async {
  final completer = Completer<Object?>();
  final match = ImperativeRouteMatch(
    pageKey: const ValueKey<String>('idempotent'),
    matches: matchList1,
    completer: completer,
  );

  match.complete('first');
  // Must not throw.
  match.complete('second');

  expect(await completer.future, 'first');
});

Verified locally:

  • dart analyze packages/go_router/ — clean.
  • flutter test packages/go_router/ — 430 passed, 2 skipped (no regressions; new test passes).

Related Issues

Pre-Review Checklist

Adds an `isCompleted` guard inside `ImperativeRouteMatch.complete` so
calling it a second time after the future has already resolved is
silently ignored instead of throwing
`Bad state: Future already completed`.

`GoRouteData` ships a default `FutureOr<bool> onExit(...) => true`,
and `go_router_builder` wires it into every generated `$route`
factory, so `routeBase.onExit != null` is true for every route
declared via `@TypedGoRoute` / `GoRouteData`. That forces every pop
through the `scheduleMicrotask` path in
`GoRouterDelegate._handlePopPageWithRouteMatch`:

  scheduleMicrotask(() async {
    final bool onExitResult = await routeBase.onExit!(...);
    if (onExitResult) {
      _completeRouteMatch(result, match);
    }
  });
  return false;

If the framework reaches `_handlePopPageWithRouteMatch` a second
time for the same `ImperativeRouteMatch` before either microtask
runs (iOS interactive back-edge gesture firing `Navigator.onPopPage`
during the commit/animation window, chained `context.pop(result)`
calls for nested modals, etc.), both microtasks call
`_completeRouteMatch`. The first one completes the Completer and
removes the match from `currentConfiguration`; the second one calls
`completer.complete(value)` against an already-completed Completer
and throws.

The pop itself succeeds — `currentConfiguration` is already updated
and `notifyListeners` rebuilds the Navigator without the popped
page before the second microtask fires — so user-visible behaviour
is unchanged. The error only shows up in apps' global error
handlers (`PlatformDispatcher.onError` / `FlutterError.onError`),
typically forwarded to crash reporting as fatal.

Issue: flutter/flutter#187326 (also
covers the chained-pop / async-modal flavors of the same defect
previously filed as #160696, #176493, #163813, #146938, #123369).

Test: `match_test.dart` now asserts that calling `complete` twice
does not throw and the second call is a no-op.
@github-actions github-actions Bot added p: go_router triage-framework Should be looked at in framework triage labels May 29, 2026
@google-cla
Copy link
Copy Markdown

google-cla Bot commented May 29, 2026

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request updates the go_router package to version 17.2.4. It modifies the complete method in ImperativeRouteMatch to be idempotent by checking if the completer is already completed before calling complete again, preventing Bad state: Future already completed errors. A regression test has been added to verify this behavior, and the CHANGELOG has been updated. I have no feedback to provide.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

p: go_router triage-framework Should be looked at in framework triage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[go_router] ImperativeRouteMatch.complete() lacks isCompleted guard — races during iOS interactive pop + chained pops

1 participant