Skip to content

fix(router): guard StackedPage pop completer and eagerly remove popped page - #1193

Closed
Eimen2018 wants to merge 2 commits into
Stacked-Org:masterfrom
Eimen2018:fix/1181-pop-completer-double-complete
Closed

Eimen2018 wants to merge 2 commits into
Stacked-Org:masterfrom
Eimen2018:fix/1181-pop-completer-double-complete

Conversation

@Eimen2018

Copy link
Copy Markdown
Collaborator

Summary

Fixes #1181: on Android back gestures with the Navigator 2 router, users hit Bad state: Future already completed and see the forward transition flash before the back animation.

Root cause

StackedPage holds a single _popCompleter per page instance and createRoute() chained popped.then(_popCompleter.complete) on every call. Flutter can call createRoute() more than once for the same Page instance. Between a pop and the async onDidRemovePage callback, the Navigator may rebuild while router.stack still contains the popped page. Seeing a page with no live Route behind it, the Navigator creates a phantom second Route. That phantom route runs an enter animation (the visual flash) and, when it eventually pops, completes the same completer a second time (the crash).

Fix

lib/src/router/stacked_page.dart:

  1. Track the latest route created for the page. Only the latest route's pop result is forwarded, and only if the completer is not already completed. A stale route can no longer complete the completer or leak its result.
  2. When the latest route pops, eagerly call router.removeRoute(routeData, notify: true) so the Navigator receives an updated pages list before it can recreate the route. _removeRoute is idempotent (lastIndexWhere then removeAt only if found), so the later onDidRemovePage call is a harmless no-op. This runs inside a Future.then microtask, after the current frame has unwound, so it does not notify during a build.

The eager-removal approach is based on #1188 by @Vinsho. Compared to that PR, this adds the latest-route check, which prevents a stale route's pop from forwarding a wrong result or removing the page prematurely.

Relation to #1182

#1182 switches RouteNavigator to the deprecated onPopPage. This PR keeps onDidRemovePage. #1182's completer guard also attaches the listener only once, so a second createRoute call returns a route whose pop is never observed and popped would never resolve. That approach is not adopted here.

Tests

test/router/stacked_page_test.dart (new, 5 tests): single pop forwards the result; createRoute called twice does not throw and forwards only the latest route's result; stale route pop is ignored; popping removes the page from the router without waiting for onDidRemovePage; with two routes only the latest triggers exactly one removeRoute. The double-completion tests fail on master without the fix.

Verification

  • flutter test: 98 passed, 1 pre-existing skip
  • dart analyze lib test: no new issues
  • No public API change

🤖 Generated with Claude Code

Eimen2018 and others added 2 commits September 18, 2026 01:12
Flutter can call createRoute more than once for the same Page instance (page reuse / predictive back). The single _popCompleter was then completed twice, throwing 'Bad state: Future already completed' on Android back gestures.

Fixes Stacked-Org#1181

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Building on the earlier latest-route + isCompleted guard, also remove
the popped page from the router's stack as soon as its route resolves,
instead of only relying on the async onDidRemovePage callback in
RouteNavigator. Flutter's Navigator can rebuild in the window between a
pop and that callback (routinely during an Android predictive-back
gesture); if `_pages` still contains the page with no live Route behind
it, the Navigator calls createRoute again for the same Page, creating a
second, phantom Route. That phantom route is what also produced the
forward-transition flash on cancelled/predictive back gestures.

The removal happens inside a Future.then callback (a microtask that only
runs after the current synchronous call stack, including any in-progress
frame's build/layout/paint, has unwound), so calling removeRoute with
notify: true here does not run during a build phase. This is unlike the
pre-existing, unrelated onDidRemovePage callback, which Flutter can invoke
synchronously mid-build (see issue Stacked-Org#1184, a separate, already-tracked
bug); that hazard is untouched by this change. removeRoute's internal
_removeRoute is also idempotent (a no-op when the page was already
removed), so the later onDidRemovePage call for the same page remains
safe.

Only the latest route (per the existing guard) triggers the removal, so
a stale route from an earlier createRoute call can neither forward a
stale pop result nor prematurely remove the page.

Approach based on Stacked-Org#1188 by @Vinsho.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Eimen2018

Copy link
Copy Markdown
Collaborator Author

Superseded by #1197, which is the same change opened from a branch in this repository instead of a fork so that CI can post the coverage report.

@Eimen2018 Eimen2018 closed this Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug]: Navigator2 - Navigation animation glitches on back gesture (Android only)

1 participant