Skip to content

[go_router] Fix ShellRoute key collisions during imperative navigation - #12549

Open
Gorniv wants to merge 2 commits into
flutter:mainfrom
Gorniv:codex/go-router-shell-route-key-stability
Open

[go_router] Fix ShellRoute key collisions during imperative navigation#12549
Gorniv wants to merge 2 commits into
flutter:mainfrom
Gorniv:codex/go-router-shell-route-key-stability

Conversation

@Gorniv

@Gorniv Gorniv commented Aug 22, 2026

Copy link
Copy Markdown

This supersedes #11143, which was closed as inactive. It incorporates the review feedback from that PR.

Problem

When push() navigates between sibling regular ShellRoute branches under the same parent, multiple shell pages and navigators can coexist in the widget tree.

This could produce duplicate page or widget keys because:

  • _CustomNavigator was keyed using GlobalObjectKey(navigatorKey.hashCode), so distinct navigator keys with the same hash were treated as the same key.
  • ShellRouteMatch.pageKey was derived only from route.hashCode.
  • Re-entering a ShellRoute that 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 configured ShellRoute.navigatorKey from consistently identifying its navigator.

Fix

This version addresses that feedback by:

  • preserving the configured navigator key during normal route matching and reparsing;
  • making shell page keys identity-aware, so distinct routes remain distinct even when their hashCode values collide;
  • keying only the nested _CustomNavigator wrapper by the navigator key object instead of its hashCode;
  • assigning scoped page and navigator keys only when an imperative push would otherwise collide with another live regular ShellRoute match;
  • deriving that scope from the persistent ImperativeRouteMatch.pageKey;
  • leaving 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 route forces both navigator-key and route-hash collisions.

It covers:

  • pushing to a sibling shell route;
  • re-entering the first shell route while its original instance is still in the stack;
  • preserving navigator and page state across match-list reconstruction and refresh();
  • detecting and scoping collisions in nested ShellRouteMatch trees;
  • popping back through both shell routes;
  • navigating with go() after the imperative stack is removed.

No screenshots are included because this is a routing/runtime fix.

Validation

  • flutter test: 438 passed, 2 skipped
  • flutter analyze: no issues
  • flutter_plugin_tools validate --check-for-missing-changes --packages go_router: no issues

Pre-Review Checklist

Footnotes

  1. 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

@github-actions github-actions Bot added p: go_router triage-framework Should be looked at in framework triage labels Aug 22, 2026
@Gorniv
Gorniv marked this pull request as ready for review August 22, 2026 13:50

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

Copy link
Copy Markdown
Contributor

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 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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

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.

Suggested change
key: GlobalObjectKey(navigatorKey),
key: ValueKey<GlobalKey<NavigatorState>>(navigatorKey),

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Comment thread packages/go_router/lib/src/match.dart Outdated
Comment on lines +685 to +692
final bool needsScopedKeys =
branch is ShellRouteMatch &&
branch.route is ShellRoute &&
newMatches.whereType<ShellRouteMatch>().any(
(ShellRouteMatch existingMatch) =>
existingMatch.pageKey == branch.pageKey ||
existingMatch.navigatorKey == branch.navigatorKey,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

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;
    }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

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

1 participant