[go_router] Fix ShellRoute key collisions during imperative navigation - #12549
[go_router] Fix ShellRoute key collisions during imperative navigation#12549Gorniv wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request resolves ShellRoute key collisions during imperative navigation by introducing custom page and navigator key classes (_ShellRoutePageKey and _ShellRouteNavigatorKey) and updating the match cloning logic to scope keys when collisions are detected. Feedback from the review suggests replacing GlobalObjectKey with ValueKey to prevent state loss during rebuilds, and implementing a recursive collision check to correctly handle nested ShellRouteMatches.
| child: _CustomNavigator( | ||
| // The state needs to persist across rebuild. | ||
| key: GlobalObjectKey(navigatorKey.hashCode), | ||
| key: GlobalObjectKey(navigatorKey), |
There was a problem hiding this comment.
Using GlobalObjectKey(navigatorKey) will cause the state of _CustomNavigator to be lost when the route match list is recreated (such as during state restoration or dynamic route updates).
This is because GlobalObjectKey overrides == to use identical(other.value, value). Even though _ShellRouteNavigatorKey overrides == and hashCode to support value equality, two different instances of _ShellRouteNavigatorKey will not be identical. As a result, GlobalObjectKey will treat them as different keys, destroying and recreating the _CustomNavigator state.
To fix this, we can use a ValueKey wrapping the navigatorKey. Since ValueKey uses value equality (==), it will correctly preserve the state across rebuilds even when the navigator key instance is recreated.
| key: GlobalObjectKey(navigatorKey), | |
| key: ValueKey<GlobalKey<NavigatorState>>(navigatorKey), |
There was a problem hiding this comment.
Thanks for flagging this. I added a regression check that serializes and decodes the full RouteMatchList, restores it, and verifies that both the scoped NavigatorState and the child state remain the same instances. This reconstructs the scoped shell keys from the persistent ImperativeRouteMatch.pageKey, and the test passes with GlobalObjectKey(navigatorKey).
I kept the global key intentionally: replacing it with ValueKey would change the wrapper from global to local key semantics, which is broader than necessary. The strengthened coverage is in 679ce61.
| final bool needsScopedKeys = | ||
| branch is ShellRouteMatch && | ||
| branch.route is ShellRoute && | ||
| newMatches.whereType<ShellRouteMatch>().any( | ||
| (ShellRouteMatch existingMatch) => | ||
| existingMatch.pageKey == branch.pageKey || | ||
| existingMatch.navigatorKey == branch.navigatorKey, | ||
| ); |
There was a problem hiding this comment.
The current collision check newMatches.whereType<ShellRouteMatch>().any(...) only inspects the top-level matches in newMatches. If there are nested ShellRouteMatches (e.g., a ShellRoute nested inside another ShellRoute), any key collisions within those nested matches will not be detected. This can lead to duplicate GlobalKey errors and runtime crashes.
We can resolve this by recursively checking for collisions throughout the entire match tree using a local recursive helper function.
final bool needsScopedKeys;
if (branch is ShellRouteMatch && branch.route is ShellRoute) {
bool hasCollision(RouteMatchBase match) {
if (match is ShellRouteMatch) {
if (match.pageKey == branch.pageKey || match.navigatorKey == branch.navigatorKey) {
return true;
}
return match.matches.any(hasCollision);
}
return false;
}
needsScopedKeys = newMatches.any(hasCollision);
} else {
needsScopedKeys = false;
}There was a problem hiding this comment.
Thanks. Fixed in 679ce61.
Rather than comparing only the outer candidate key against the existing tree, the push logic now collects all existing ShellRouteMatch nodes and checks each regular shell node while cloning the new branch. This scopes only the nested shell whose page or navigator key actually collides, while preserving the configured keys of non-colliding outer shells.
I also added a focused regression test. It failed against the previous top-level-only check and passes with the recursive per-node check.
This supersedes #11143, which was closed as inactive. It incorporates the review feedback from that PR.
Problem
When
push()navigates between sibling regularShellRoutebranches under the same parent, multiple shell pages and navigators can coexist in the widget tree.This could produce duplicate page or widget keys because:
_CustomNavigatorwas keyed usingGlobalObjectKey(navigatorKey.hashCode), so distinct navigator keys with the same hash were treated as the same key.ShellRouteMatch.pageKeywas derived only fromroute.hashCode.ShellRoutethat was already present in the imperative stack reused its page and navigator identities.The first implementation in #11143 created fresh page and navigator keys for every
ShellRouteMatch. Review correctly pointed out that this made navigator identity unstable across route reparses and prevented the configuredShellRoute.navigatorKeyfrom consistently identifying its navigator.Fix
This version addresses that feedback by:
hashCodevalues collide;_CustomNavigatorwrapper by the navigator key object instead of itshashCode;ShellRoutematch;ImperativeRouteMatch.pageKey;StatefulShellRoute, the root navigator wrapper, and normal configured-key navigation unchanged.Tests
The regression test
push to a sibling shell route under the same parent shell routeforces both navigator-key and route-hash collisions.It covers:
refresh();ShellRouteMatchtrees;go()after the imperative stack is removed.No screenshots are included because this is a routing/runtime fix.
Validation
flutter test: 438 passed, 2 skippedflutter analyze: no issuesflutter_plugin_tools validate --check-for-missing-changes --packages go_router: no issuesPre-Review Checklist
[shared_preferences]///).Footnotes
Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling. ↩ ↩2