Repository navigation
Conversation
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>
This was referenced Sep 17, 2026
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #1181: on Android back gestures with the Navigator 2 router, users hit
Bad state: Future already completedand see the forward transition flash before the back animation.Root cause
StackedPageholds a single_popCompleterper page instance andcreateRoute()chainedpopped.then(_popCompleter.complete)on every call. Flutter can callcreateRoute()more than once for the samePageinstance. Between a pop and the asynconDidRemovePagecallback, the Navigator may rebuild whilerouter.stackstill 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:router.removeRoute(routeData, notify: true)so the Navigator receives an updatedpageslist before it can recreate the route._removeRouteis idempotent (lastIndexWherethenremoveAtonly if found), so the lateronDidRemovePagecall is a harmless no-op. This runs inside aFuture.thenmicrotask, 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
RouteNavigatorto the deprecatedonPopPage. This PR keepsonDidRemovePage. #1182's completer guard also attaches the listener only once, so a secondcreateRoutecall returns a route whose pop is never observed andpoppedwould never resolve. That approach is not adopted here.Tests
test/router/stacked_page_test.dart(new, 5 tests): single pop forwards the result;createRoutecalled 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 foronDidRemovePage; with two routes only the latest triggers exactly oneremoveRoute. The double-completion tests fail onmasterwithout the fix.Verification
flutter test: 98 passed, 1 pre-existing skipdart analyze lib test: no new issues🤖 Generated with Claude Code