fix(flow): elect the topmost element under a recorded tap - #593
Closed
latekvo wants to merge 2 commits into
Closed
Conversation
`nodeAtPoint` ranked the visible nodes containing a tapped point by frame area and took the smallest. Frames on these trees are not clipped by what is drawn over them, so on Android a feed row under a bottom tab bar is reported at its laid-out bounds and a wide-but-short text leaf inside it beats the tab button on area — recording captured the buried node and the replayed step hit a different element, silently. Elect by paint order instead: a nested frame beats the frame containing it (a control over its container), and frames that nest neither way are separate branches drawn over one another, so the later-listed one takes the touch. Both inputs hold on every flow tree source, so no per-source case. Where only paint order separates the candidates, `nodesStackedAtPoint` names them and the recorder folds a caveat into the step's message rather than letting the pick pass for certain. `selectorToFrame` keeps ranking matches by specificity: it asks which of a selector's matches the author meant, not which of the elements over one point the finger reached. Its prose and `comparePick`'s no longer claim a shared doctrine with the hit test.
The fixture's pixel rectangles come from two screens of one app on one device, not from a single screen, and no stock Android app draws the overlapping layout at all - say so where the numbers live.
Member
Author
|
This is the wrong approach. We should enforce lack of ambiguity instead. |
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.
Symptom
QA on Bluesky, physical Android phone:
Root cause
Two facts that are each fine alone and wrong together.
Frames are not clipped by what is drawn over them. A uiautomator dump reports a view at its laid-out bounds regardless of occlusion, and the flow tree keeps that. Measured on a Pixel 3a API 34 emulator (1080x2220): a bottom tab bar is
[0,1934][1080,2220]while the scroll container behind it runs to[0,242][1080,1934]and, on a list that reaches the screen bottom, to[0,245][1080,2220]. Feed rows under the bar are really there, at their real bounds.nodeAtPointranked by area. It walked the whole tree and kept the smallest visible frame containing the point:A tab button is 216x220 px = 47 520 px². A feed row's text leaf measured on the same device is 636x50 px = 31 800 px² - wider than the button, yet smaller in area, which is exactly the shape the report describes. So the buried text leaf won,
deriveSelectorturned it intotap: { text: … }, and replay resolved a feed row on the other side of the screen.Paint order was ignored entirely.
What changed
nodeAtPointis now a hit test. Among the visible candidates containing the point,paintsOverdecides pairwise, on geometry alone:WITHIN_EPSput the same point under the finger, so the incumbent stands (unchanged from the old strict<).The election is a single left-to-right reduction over the candidates in tree order, so "later" means "later in the tree" without reconstructing ancestry - which matters, because
nodeAtPoint's only production caller feeds it a flat tree. Every flow adapter (flow-android-tree,flow-ios-tree,flow-chromium-tree,flow-vega-tree) runsflattenHoisting, which emitschildren: []leaves under one synthetic root in post-order - descendants precede their container. Verified against a real dump:adaptFullAndroidHierarchyToDescribeResulton the emulator's Clock screen yieldsflat leaves: 51 | nested? false. A recursive "descend children in reverse" walk would have been a no-op there, and a plain "last containing node wins" would have elected the outermost container every time.Cross-platform: deliberately not source-aware
Both inputs the rule uses survive every source, which is why there is no per-platform branch:
subviews, Android the view-child order uiautomator walks, and both paint earlier siblings first. On Chromium it is CSS paint order for everything the cascade does not reorder.What is left is genuine ambiguity, not a tie-break away from being solved: a
z-index-reordered pair on Chromium, or Vega's undocumented page-source ordering. Those are reported, not guessed - see below. The rationale lives innodeAtPoint's doc comment.The missing warning
nodesStackedAtPointreturns the visible candidates that cover the point while nesting neither way with the elected element - i.e. something genuinely overlaps the touch. Containers of the pick are excluded (they are not contenders), and so are equal-framed nodes (same tap point either way).captureTapSelectorfolds that into the existingwarningchannel:Warnings now compose (
joinWarnings): a capture can be both from a fallback tree source and over a contested point, and dropping either would understate the caveat. Empty for the ordinary tap, so quiet recordings stay quiet.selectorToFrame- the divergence, resolved in proseIts ranking is unchanged: exact fields, then smallest frame, then reading order. Changing it would touch every
tap/type/assertand is not what the report is about; more importantly the two functions answer different questions. A hit test knows a point and asks which of the elements covering it the finger reaches, so paint order decides.selectorToFrameranks the elements a selector matches - most of which do not overlap at all - and asks which one the flow author meant, which is a question about fit, not stacking. That is now said explicitly in its docstring, and the three sites that claimed a single shared "smallest frame wins" doctrine no longer cross-referencenodeAtPoint: its own docstring, thecomparePickcomment, and the test comment onselectorToFrame prefers the smallest of several exact matches.Existing test expectations I changed
nodeAtPoint returns the smallest element under a point->... returns the element nested inside a container under a point. The assertion is unchanged and still passes: the fixture's overlappingAXGroupgeometrically contains theAXButton, so the nesting rule elects the button just as the area rule did. Only the name and comment changed - they asserted a doctrine the code no longer follows, and the name would have misled the next reader about why the button wins.selectorToFrame prefers the smallest of several exact matches- comment only. It said "same philosophy asnodeAtPoint", which is now false; it explains the specificity-vs-stacking split instead.No existing expectation was weakened or deleted.
Verification
Reproduced first, on the reporter's geometry. The new fixture is built from pixels measured with
uiautomator dump --compressedon an allocated Pixel 3a API 34 (bar[0,1934][1080,2154], 216x220 px tabs, an edge-to-edge list at[0,245][1080,2220], 154 px rows, a 636x50 px row text leaf), arranged in the flat post-order shapeflattenHoistingemits. Withsrc/stashed toorigin/mainand the tests kept:expected undefinedis the bug: onorigin/mainthe elected node is the feed text leaf, which carries no identifier.Mutation checks - each rule pinned independently, restoring in between:
return candidateInside)return incumbentInside)return true)keeps the first of two elements sharing one frame)fallbackSourceWarningdropped from the joinoverlapWarningdropped from the joinThe third came back green on the first attempt - the equal-frames tie-break was unpinned, so I added
nodeAtPoint keeps the first of two elements sharing one framebefore re-running.Gates, all from the worktree root:
npx tsc --build,npx prettier --check .,npx eslint . --max-warnings 0,npm run typecheck:tests -w @argent/tool-server,npm test -w @argent/tool-server-> 295 files, 3077 passed, 1 skipped. No flakes hit this run.On a real device (allocated
emulator-5554, freed after; worktree tool-server driven overPOST /tools/:name), against the real Android full-hierarchy flow tree rather than a fixture:flow-start-recording+flow-add-stepagesture-tapon the Clock app's bottom nav at (0.5, 0.9207), run twice - once with this branch's build, once withsrc/stashed toorigin/mainand rebuilt:origin/main->id="…:id/navigation_bar_item_icon_view"(smallest frame, 4 356 px²)id="…:id/navigation_bar_item_active_indicator_view"(topmost, 6 160 px²)com.argent.androiddevtoolshelper over its forwarded socket and replayed both rules offline through the builtadaptFullAndroidHierarchyToDescribeResult- the offline run reproduces the two live picks exactly, which is what validates the harness. It also confirms the flat/post-order shape (51 leaves, nested? false).flow-add-stepon the Add-city FAB recordedtap: { id: …:id/fab_container }with no caveat (correctly - everything else under the point contains the pick), thenflow-finish-recordingandflow-execute->ok: true, passed: 1.What I could not reproduce end to end: the unclipped-overlap shape itself, on a device. I swept the emulator's stock apps (Clock, Contacts, Photos, Maps, Gmail, Play Store, launcher) and the installed RN/Expo test builds with a script looking for exactly this pattern and found none - native Android layouts put the bar beside the content, not over it, so nothing overlaps. The report's app is React Native with a translucent tab bar over the feed. I patched a local Expo fixture app to
tabBarStyle: { position: "absolute" }to produce it for real, but its installed dev build no longer matches its JS (ExpoLinearGradientview manager missing, Reanimated 4.1.6 vs 4.1.7) and redboxed; the file was restored byte-identical. So the fixture geometry is measured on the device and composed into the reported layout rather than captured from one screen - stated as such in the test comment.Deliberately out of scope
The "still gets a PASS" half is not closed. With the right element recorded the replay resolves the right selector, so the primary defect is gone - but the pass criterion itself is untouched.
runTap(flow-actions.ts:707-718) dispatchesgesture-tapand returns{ ok: true };execLeafStep(flow-run.ts:1074) maps that straight tostatus: "pass". Selector resolution alone is the pass criterion: no post-tap tree read, no landed-on-target assertion, no before/after fingerprint diff (treeFingerprint's two call sites are both pre-action), and no occlusion notion anywhere on the replay path -isVisibleis area-only. A tap onto an occluding overlay, a disabled control, or a container whose centre sits over a different child still reports PASS; the only guard is an author-writtenassert/await/snapshotstep. Closing it means adding a verification step to the replay path plus awarningfield toStepReport(the CLI already renders one -argent-cli/src/flow.ts:26, glyph at:200, counted at:215- but no server code emits it any more). That is a separate change with its own blast radius.A candidate whose frame fits entirely inside the tab button is indistinguishable, from frames plus a flattened tree, from the tab's own icon or label leaf: in post-order a real descendant also precedes its container, so both "nested and earlier" cases look identical. The nesting rule therefore elects it, and no caveat fires - warning there would fire on every ordinary tab tap. The reported shape is the wider overlapping element, which the stacking rule does fix. Separating the two needs a non-geometric signal (Android's
clickableis the obvious candidate, but it is platform-skewed and rows are often clickable too).No platform was scoped out, but the residual ambiguity is unevenly distributed: iOS and Android give a real back-to-front sibling order, Chromium gives one only absent
z-index, and Vega's is undocumented. The caveat is what covers the last two.Interaction with open PRs
Checked #589, #581, #574 and merged #569:
feat/concurrent-flow-recordingseditsflow-add-step.tsheavily but not one line insidecaptureTapSelector. It does rewrite themessagetemplate and thewarningassignment inexecute, which this PR leaves untouched - so the two are same-file, non-overlapping. Whoever rebases second should keepjoinWarnings' output flowing into its newStep added to "${params.name}"template.codex/flow-screen-selectorstouchesui-tree-match.tsonly atfetchTree, ~155 lines belownodeAtPoint. Worth flagging semantically: it adds a screen tree source tofetchFlowTree, whilecaptureTapSelectorstill captures against the app tree - a hit test and a caveat computed on one tree say nothing about the other. The caveat's wording is about the tapped point, not a guarantee about replay, so it stays true; but the pairing deserves a look when feat: support screen-sourced flow selectors #589 lands.clearon thetypedirective #581feat/flow-type-cleartouches none of these files (and targetsfeat/keyboard-clear, notmain). ItswaitForFocusreadsselectorToFrame, whose ranking this PR deliberately leaves alone.