From 1f4ad7a2370d6a0280c765f2a1e4853dfe3053af Mon Sep 17 00:00:00 2001 From: Reni Delonzek Date: Wed, 17 Jun 2026 22:21:57 +0700 Subject: [PATCH] fix: prefer topmost route when resolving tap/enter_text by text/key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a screen is pushed over another that stays mounted, _findElementByText and _findElementByKey returned the first match in a pre-order tree walk — which is the background route (mounted first). So tap(text:)/tap(key:) could hit an element hidden behind the current screen (e.g. a tab label on the visible screen that also exists as a list subtitle on the screen behind it). Collect all matches and return the last one. In Overlay/Navigator order the foreground route is mounted last and therefore visited last, so the last match targets the screen the user actually sees. A center hit-test could refine this further and is noted as a follow-up. Applied to both the canonical lib/ source and the bundled npm copy. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/flutter_skill.dart | 40 +++++++++++++++-------- packaging/npm/dart/lib/flutter_skill.dart | 40 +++++++++++++++-------- 2 files changed, 52 insertions(+), 28 deletions(-) diff --git a/lib/flutter_skill.dart b/lib/flutter_skill.dart index a9b63aa7..deca072b 100644 --- a/lib/flutter_skill.dart +++ b/lib/flutter_skill.dart @@ -1040,14 +1040,12 @@ class FlutterSkillBinding { // ==================== ELEMENT FINDING ==================== static Element? _findElementByKey(String key) { - Element? found; + final matches = []; void visit(Element element) { - if (found != null) return; final widget = element.widget; if (widget.key is ValueKey && (widget.key as ValueKey).value == key) { - found = element; - return; + matches.add(element); } element.visitChildren(visit); } @@ -1057,21 +1055,17 @@ class FlutterSkillBinding { if (binding.rootElement != null) { visit(binding.rootElement!); } - return found; + return _preferTopmostMatch(matches); } static Element? _findElementByText(String text) { - Element? found; + final matches = []; void visit(Element element) { - if (found != null) return; final widget = element.widget; if (widget is Text && widget.data == text) { - found = element; - return; - } - if (widget is RichText && widget.text.toPlainText() == text) { - found = element; - return; + matches.add(element); + } else if (widget is RichText && widget.text.toPlainText() == text) { + matches.add(element); } element.visitChildren(visit); } @@ -1081,7 +1075,25 @@ class FlutterSkillBinding { if (binding.rootElement != null) { visit(binding.rootElement!); } - return found; + return _preferTopmostMatch(matches); + } + + /// Picks the best match among duplicates that can appear across stacked + /// routes/overlays. + /// + /// The element tree is walked in pre-order, so a background route (mounted + /// first, still alive underneath a pushed route) is collected *before* the + /// foreground route. Returning the first match would therefore tap an element + /// that is invisible behind the current screen — e.g. a tab label on the + /// visible screen that also exists as a list subtitle on the screen behind it. + /// + /// In Overlay/Navigator order the foreground route is mounted last, so it is + /// visited last. Returning the last match targets the screen the user actually + /// sees. (A hit-test at each candidate's center would be even more precise and + /// could be layered on top of this later.) + static Element? _preferTopmostMatch(List matches) { + if (matches.isEmpty) return null; + return matches.last; } static Element? _findElement({String? key, String? text}) { diff --git a/packaging/npm/dart/lib/flutter_skill.dart b/packaging/npm/dart/lib/flutter_skill.dart index 1c70882a..cbb2a219 100644 --- a/packaging/npm/dart/lib/flutter_skill.dart +++ b/packaging/npm/dart/lib/flutter_skill.dart @@ -485,14 +485,12 @@ class FlutterSkillBinding { // ==================== ELEMENT FINDING ==================== static Element? _findElementByKey(String key) { - Element? found; + final matches = []; void visit(Element element) { - if (found != null) return; final widget = element.widget; if (widget.key is ValueKey && (widget.key as ValueKey).value == key) { - found = element; - return; + matches.add(element); } element.visitChildren(visit); } @@ -502,21 +500,17 @@ class FlutterSkillBinding { if (binding.rootElement != null) { visit(binding.rootElement!); } - return found; + return _preferTopmostMatch(matches); } static Element? _findElementByText(String text) { - Element? found; + final matches = []; void visit(Element element) { - if (found != null) return; final widget = element.widget; if (widget is Text && widget.data == text) { - found = element; - return; - } - if (widget is RichText && widget.text.toPlainText() == text) { - found = element; - return; + matches.add(element); + } else if (widget is RichText && widget.text.toPlainText() == text) { + matches.add(element); } element.visitChildren(visit); } @@ -526,7 +520,25 @@ class FlutterSkillBinding { if (binding.rootElement != null) { visit(binding.rootElement!); } - return found; + return _preferTopmostMatch(matches); + } + + /// Picks the best match among duplicates that can appear across stacked + /// routes/overlays. + /// + /// The element tree is walked in pre-order, so a background route (mounted + /// first, still alive underneath a pushed route) is collected *before* the + /// foreground route. Returning the first match would therefore tap an element + /// that is invisible behind the current screen — e.g. a tab label on the + /// visible screen that also exists as a list subtitle on the screen behind it. + /// + /// In Overlay/Navigator order the foreground route is mounted last, so it is + /// visited last. Returning the last match targets the screen the user actually + /// sees. (A hit-test at each candidate's center would be even more precise and + /// could be layered on top of this later.) + static Element? _preferTopmostMatch(List matches) { + if (matches.isEmpty) return null; + return matches.last; } static Element? _findElement({String? key, String? text}) {