deepestAccessibilityHitPath
Returns the path from node down to the deepest descendant whose accessibility frame contains positionInWindowPx, or null when nothing in the tree does.
The full root-to-leaf path is returned, not just the leaf, so callers can inspect ancestry — pick the nearest clickable ancestor of the hit node, or detect that the path crosses a view owned by another capture pipeline.
The starting node is not a filter. Containment gates the descent at every node except node itself: node is the caller's choice of where to search, and its own frame says nothing about where its descendants are. This is not a hypothetical distinction — it is the difference between working and reporting nothing at all. Measured on a simulator created fresh, with no accessibility client ever connected to the process, Compose Multiplatform's OverlayInputView (the view autograph-compose starts this walk from, via LocalUIView.current) reports accessibilityFrame = CGRectZero, while every bridged element beneath it already carries a correct frame, identifier and traits. Gating on the starting node dropped every Compose tap in that state, for the life of the process — and the tap itself was never in doubt: the element's own onClick fired each time. Anything that connects to the accessibility subsystem (XCUITest, VoiceOver, the Accessibility Inspector) populates that frame and hides the whole failure, which is why the sample-ios XCUITest suite passed throughout: its runner is itself such a client. See #135.
This exemption does not rescue a UIKit/SwiftUI walk in that state, and the reason is not the one an earlier version of this note gave (it claimed the native walk's UIWindow reports a valid frame when cold — measured false, it reports CGRectZero too). Cold, UIKit and SwiftUI have not built an accessibility tree at all: the walk reaches only plain UIViews through subviews, every one of them reporting an empty frame and no traits, with not a single SwiftUI.AccessibilityNode or button trait anywhere. So the exemption gets the walk past the root and every child then prunes on its own empty frame. Compose differs because Compose Multiplatform builds its bridged elements itself, on an activation path that reading the tree is enough to trigger (see the note at the top of this file) — those are present and correct while cold, which is why exempting the root is enough there and only there. It cost the native pipeline its SwiftUI half, which is why #191 stopped that pipeline using this walk at all — see installAutographNativeTapCapture.
What the exemption does not loosen. Two properties are preserved deliberately, because relaxing the descent could otherwise turn a dropped event into a misattributed one — the worse failure:
A node that does not contain the position is never returned alone; only as an ancestor of a descendant that does. Every other element on a returned path contains the position.
A node that is itself clickable keeps its gate, so it is never on a path it doesn't contain. It stays visible to ancestry checks on the paths it does contain, and there is no way to hold it in the path for those while withholding it from nearestAccessibilityClickable, which knows no geometry. Measured: without that clause a clickable starting node whose frame missed the tap was attributed the tap whenever any inert child contained it.
One consequence is genuinely new: a tap outside node's own frame can now resolve, where it previously always dropped. Both shipped callers pass a root that contains every tap they are asked about (a UIWindow, or the Compose host's overlay view), so this widens where the walk's documented overlap ambiguity below can be reached without changing which element any current tap names.
Overlap tie-break, and its limits. Children are searched in reverse order, so among subviews a later sibling — the one drawn on top — wins an overlap. That is a true z-order tie-break only for subviews, and it fails in two measured ways.
Across groups: accessibilityChildren returns accessibilityElements + subviews, a concatenation whose across-group order has no relation to what is drawn on top, and reversing it searches every subview before every accessibility element. So a node that exposes an on-top overlay through accessibilityElements while the covered content is a plain subview resolves a tap to the covered subview instead of the overlay.
Within accessibilityElements: the order there is whatever the element's provider chose, and nothing obliges a provider to choose z-order. Across nine Compose Multiplatform fixtures the emitted order fit (left, top) lexicographically — x-primary. That is a fit to those nine, not a contract; what the fixtures do establish is the two things it is not. It is not declaration order (one fixture declares its small element first and the bridge emits it second), and it is not y-primary reading order (one fixture emits the element with the smaller top second). Either way the order is unrelated to what is drawn on top, so reversing it breaks an overlap in favour of an element chosen for reasons that have nothing to do with which one received the tap. See #140.
What narrows this, and only on Compose. The bridge subtracts an occluding sibling's rect from the covered sibling's accessibilityFrame when the remainder is still an axis-aligned rectangle — an edge strip. Measured in three fixtures, one per direction (top, bottom, left): the covered element lost exactly the strip its neighbour covered, and no other fixture was trimmed. Since the trim lands on the covered element it is itself a z-order signal, and where it applies the covered element stops containing the tap, so the ambiguity is gone before this tie-break is consulted. It follows real draw order rather than declaration order: two fixtures with identical geometry and identical declaration order, differing only by Modifier.zIndex, trim differently. No trim was observed where the remainder would not be one rectangle — measured for corner overhangs, where both siblings kept full frames and both contained the tap.
So the resulting misattribution takes two different shapes:
Compose Multiplatform: the overlap is not trimmable and the element on top sorts earlier under the order above (further left, or same left and higher). Both are required — a leftward or upward overhang whose overlap is trimmable resolves correctly, as three fixtures did. Measured to resolve correctly: a full-width overlay over content and a horizontal overlap, because the trim settles them; a badge overhanging to the top-right, because although its corner overlap is not trimmable the badge sits further right, so it sorts later and wins the tie-break on its own. The measured failure is a corner overhang straight up — the two elements share a
left, and the one on top has the smallertop, so it sorts first. A strictly leftward overhang follows from the order above but was never run: no fixture put the on-top element at a smallerleft.UIKit / SwiftUI: no trim was observed. In the one geometry Compose does trim — a covered element under a full-width strip — a SwiftUI button reported the same frame as an identical un-overlapped one, so the rescue above is absent and the tie-break is left to decide alone. That makes the same overlap that Compose disambiguates a candidate for misattribution here. Measured for SwiftUI only; no UIKit hierarchy was run, and one trimmable geometry is not a sweep. Since #191 this no longer reaches the native pipeline, which does not use this walk; it remains a live concern for
autograph-compose, whose iOS resolver does.
Do not reach for the obvious rankings. Scored against the oracle — which element's handler actually fired — over the five measured ambiguous cases, each is refuted: last-emitted (what this walk does) by the overhang above; smallest-area by two fixtures where the larger element is on top; first-emitted by four. Smallest-area survived a first round only because the fixture built to refute it had a trimmable overlap, so the trim removed the covered element from the candidate set before the rule was tested. ("Most-specific-frame", the other ranking #140 floated, coincides with smallest-area on every one of these fixtures and was not scored as a separate rule.) Three refuted rules are not the whole space, but they are the ones worth not re-deriving.
All of this is documented rather than fixed — this walk is shared API and its callers should know the edge of the contract they depend on.
Clickable branches win over the tie-break. Before z-order is consulted at all, a branch that yields a clickable (isAccessibilityButton) is preferred over one that yields none; the reverse order above decides only among branches that tie on that. Without this the walk commits to the first branch geometrically containing the point and never reconsiders, so a single empty view covering the content swallows every tap. That is not hypothetical — measured on a real SwiftUI List, where a full-screen _UITouchPassthroughView sits on top of the cells and, as its name says, passes touches straight through to them. UIKit's own hitTest gets this right because such views decline the hit; the accessibility tree carries no equivalent signal, so "did this branch lead anywhere a tap can be attributed to" is the closest available stand-in.
The trade this accepts: an opaque non-interactive overlay that genuinely does block touches — a modal scrim over a button, say — is likewise invisible to this walk, and a tap on it now resolves to the button beneath instead of to nothing. Both directions are wrong for some tree; this one is wrong for the rarer one, and it fails toward reporting an event rather than toward the pipeline being silently inert on every SwiftUI screen built out of List.
Note this is decided per branch, not globally: a subtree with no clickable anywhere still resolves exactly as before, so callers that don't care about clickability see no change.
preferClickableBranches turns the preference off, restoring the plain topmost-branch descent. That is not a compatibility shim: the preference answers "which element should this tap be attributed to", and a caller asking "where did this tap visually land" needs the other answer. The removed native resolver needed both — it decided Compose ownership from the topmost path, because a preference for clickables would otherwise route the path around a Compose host and let the native pipeline claim a tap that landed on Compose-owned content — and resolved the target from the preferred one. Conflating the two is exactly the bug that motivated splitting them.
view supplies the coordinate space and scale the point-to-pixel ratio — both are handed to accessibilityBoundsInWindowPx unchanged, so its precondition on scale applies here too.
Threading. Main thread only: every property this reads (accessibilityChildren, accessibilityFrame, subviews) is main-thread-only UIKit API.
Termination. The tree this walks is supplied by the host app, not by this module, and nothing in the UIAccessibility contract forbids an element from listing an ancestor among its accessibilityElements. Such a link makes the graph cyclic, and a naive descent would recurse until the stack overflows. Three bounds prevent that: a branch that revisits a node already on the path being built is abandoned (the parent then tries its next sibling), descent stops at MAX_ACCESSIBILITY_TREE_DEPTH, and the walk as a whole stops after MAX_ACCESSIBILITY_NODE_VISITS nodes. The last is what bounds breadth rather than depth, and it is load-bearing precisely because of the clickable preference above: exploring every branch of a clickable-free subtree compounds per level whenever a node is reachable by more than one route. All three degrade to resolving a shallower element rather than crashing — a missed leaf is a dropped event, an overflow or a multi-second walk on the main thread is a wedged app.
allowScopeContainerDescent is off by default, and only the Compose pipeline turns it on. It exempts an Autograph scope container (AUTOGRAPH_SCOPE_IDENTIFIER_PREFIX) from the containment gate, so a clickable drawn outside its scope wrapper still resolves. Confining it to the Compose adapter is what keeps two other properties intact by construction rather than by argument:
The ownership boundary. Both pipelines drop a tap whose path crosses a Compose host, which is how they avoid reporting the same tap twice. Historical, and stated because the shape of the argument still applies: until #191 the native pipeline arbitrated that boundary through this very walk, over the whole window — so a branch that no longer pruned on containment could resolve before the branch through the host, neither returned path would cross it, and a Compose-owned tap could be reported natively. Since #191 the native pipeline asks the question of the
UIView.hitTestchain instead (seeNativeHitTestResolution.kt) and does not use this walk at all, so that particular route is closed — but the marker is still an identifier the host app is free to write, so it still cannot be treated as proof of who owns a subtree, and the exemption still must not escape the Compose adapter. The Compose walk starts at the Compose host, so it stays inside one composition's subtree and never reaches a sibling of that host. Note what that does not say: accessibilityChildren unionsaccessibilityElementswithsubviews, so the walk does descend into UIKit interop (UIKitView/UIKitViewController) subtrees hosted inside the composition, and those are not Compose-owned. The exemption can therefore apply to a foreign node — bounded, since the reported clickable must still contain the tap and the native pipeline still drops any path crossing this host, but it is a bound rather than the absence of a case. An earlier draft of this note claimed everything reachable here is Compose-owned; it is not.The visit budget. A non-containing branch that used to cost one visit can expose a whole subtree instead. Confined to the Compose walk that subtree is the composition's own; opened on the native walk it is the entire window, twice per tap.