Skip to content

[go_router] Fix spurious onExit when refreshListenable re-parses a pushed route - #12212

Open
davidmigloz wants to merge 3 commits into
flutter:mainfrom
davidmigloz:upstream-imperative-match-completer-identity
Open

davidmigloz wants to merge 3 commits into
flutter:mainfrom
davidmigloz:upstream-imperative-match-completer-identity

Conversation

@davidmigloz

@davidmigloz davidmigloz commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor

GoRoute.onExit fires spuriously for a pushed route that never left the navigation stack whenever refreshListenable re-parses that route. The same round-trip can also replace the live state.extra: JSON Map and List values lose identity, a custom extraCodec may return a new object without value equality, and an unserializable in-memory object without a codec decodes as null.

The encoded configuration cannot preserve either the push completer or the identity of its extra. A decoded ImperativeRouteMatch therefore compares unequal to the live match even when it represents the same pushed route. That defeats setNewRoutePath's no-op fast path, runs onExit, and can replace the live route state.

On the re-decode path only, this change reconciles each decoded imperative match with the live match that has the same pageKey and URI. Reusing the whole live match preserves both its completer and exact extra object, making an unchanged refresh parse equal to the configuration already on screen. The URI check prevents a key reused by replace from matching a different route. If there is no live match or its URI differs, the decoded match is retained for genuine restoration or history navigation.

ImperativeRouteMatch.operator== and hashCode remain unchanged, and imperative push/replace/pushReplacement navigations do not use this reconciliation path. An earlier version of this PR excluded the completer from equality; that regressed same-location replace() by orphaning its returned future, so the final design preserves identity at decode time instead.

Regression coverage includes:

  • no spurious onExit on a refresh tick;
  • exact extra identity for JSON Map and List values;
  • exact extra identity for a custom-codec object without value equality;
  • preservation of an unserializable in-memory extra without a codec;
  • push-future completion across the refresh;
  • same-location replace() future completion;
  • reconciliation only when both pageKey and URI match, with decoded matches retained when no live match exists or the URI differs.

Validated after rebasing onto current main (go_router 18.1.2):

  • focused match_test.dart and on_exit_test.dart suites;
  • package and example test suites on the VM, Chrome, and Chrome/Wasm;
  • repository format, analyze, validate, and publish dry-run checks.

Fixes flutter/flutter#189578

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 Jul 16, 2026

@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 excludes the ephemeral completer from the ImperativeRouteMatch equality (==) and hashCode calculations to prevent spurious onExit calls when a route is re-decoded (e.g., during a refreshListenable tick). It also adds corresponding unit and widget tests to verify this behavior. One review comment suggests wrapping an unawaited router.push call in unawaited to satisfy the unawaited_futures lint rule.

@davidmigloz davidmigloz changed the title [go_router] Exclude completer from ImperativeRouteMatch equality [go_router] Fix spurious onExit when refreshListenable re-parses a pushed route Jul 16, 2026
@davidmigloz
davidmigloz force-pushed the upstream-imperative-match-completer-identity branch from 7133569 to 4756afd Compare July 16, 2026 14:07
@Piinks
Piinks requested a review from chunhtai July 21, 2026 22:18

@chunhtai chunhtai 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.

The approach Looks good to me, left some comments

Comment thread packages/go_router/test/match_test.dart Resolved
@chunhtai chunhtai added CICD Run CI/CD and removed p: go_router labels Aug 1, 2026
@flutter-dashboard flutter-dashboard Bot removed the CICD Run CI/CD label Aug 1, 2026
davidmigloz added a commit to davidmigloz/flutter_packages that referenced this pull request Aug 3, 2026
…s onExit

A refreshListenable tick re-parses the current location; the decoded
ImperativeRouteMatch minted a fresh completer, so the configuration
compared unequal and onExit fired without any real exit. Reconciles live
completers by pageKey on the re-decode path (reconcileImperativeCompleters)
instead of changing ImperativeRouteMatch identity, so same-location
replace() keeps working and awaited push() futures resolve.

Squash of the two commits on the upstream PR branch:
flutter#12212
@stuartmorgan-g

Copy link
Copy Markdown
Contributor

From triage: @chunhtai what is the status of this PR? You've said LGTM above, but there's no approval, and no second reviewer added.

@loic-sharma
loic-sharma requested a review from Piinks October 27, 2026 20:50
Comment thread packages/go_router/lib/src/match.dart Resolved
…s onExit

A `refreshListenable` tick (e.g. tab refocus) re-parses the encoded current
configuration. The codec mints a fresh `Completer` per `ImperativeRouteMatch`,
so the re-decoded list compares unequal to the live one, the delegate's no-op
fast path is defeated, and `onExit` fires on a route that never left the stack.

Instead of dropping the completer from `ImperativeRouteMatch` identity — which
breaks `replace()` to the same location (it reuses the old pageKey and differs
only by its new completer, so the new configuration would compare equal, the
`setNewRoutePath` fast path would no-op, and the returned future would be
orphaned) — reconcile completers only on the re-decode path: for each decoded
imperative match, adopt the live completer of the match with the same pageKey
(unique per push). The re-decoded list then compares equal including completer,
the fast path holds naturally, and in-flight push futures are preserved.

Tests: onExit not fired on a refresh tick; live push completer resolves across
a refresh; replace() to the same location still completes its future;
reconcileImperativeCompleters adopts live completers by pageKey and keeps fresh
ones when there is no live match.
@davidmigloz
davidmigloz force-pushed the upstream-imperative-match-completer-identity branch from c410139 to 694d712 Compare October 30, 2026 23:12
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] onExit fires spuriously for a pushed route when refreshListenable fires

4 participants