Skip to content

Commit 535b5fd

Browse files
Remove UIView scoping from the index-API fallback
The `!(self is UIView)` guard in `resolvedAccessibilityElements()` was added (commit 6c5ef33) to fix a parser hang, attributed to "UIKit synthesizes accessibilityElementCount() from the subview tree." Probing UIKit shows that rationale is false: plain, child-bearing, and `shouldGroupAccessibilityChildren` views all return `NSNotFound` (== `NSIntegerMax`) from `accessibilityElementCount()`, not a synthesized count. The hang was caused by the old `count > 0` check letting `NSNotFound` through, looping ~`NSIntegerMax` times — the actual fix for which is the `count != NSNotFound` sentinel guard added later (commit b991cfc). With the sentinel guard in place the `UIView` exclusion is no longer needed to prevent the hang, and it actively defeats the purpose of the fallback: a `UIView` that exposes its children only through the `UIAccessibilityContainer` index APIs (nil `accessibilityElements`) is exactly what the fallback should capture, and is exactly what VoiceOver consumes. Removing the guard lets such views be resolved correctly while leaving every standard UIKit view untouched. Verified that no view is affected in practice: probing 18 UIKit view types, none sit in the "nil accessibilityElements + finite positive count" bucket — they either populate `accessibilityElements` (e.g. UISegmentedControl, UIPickerView, UINavigationBar) or return `NSNotFound`/`0`. The only index-API implementations in this repo are the two test helpers, so guard removal changes no existing output. - Remove the `!(self is UIView)` guard and rewrite the doc comment to describe the single, honest rule (accessibilityElements, else index APIs, else nil). - Invert `testViewContainerIgnoresIndexAPIsInFavorOfSubviews` -> `testViewContainerResolvesElementsViaIndexAPIs`: a view that vends only via the index APIs is now resolved through the fallback, with index elements taking precedence over subview traversal (matching VoiceOver). Snapshot tests are gated to recorded device configs (iOS 17.5 / 18.5 / 26.2) and must be validated in CI; local iOS 26.5 runs are not collected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f988545 commit 535b5fd

2 files changed

Lines changed: 24 additions & 34 deletions

File tree

‎Example/UnitTests/AccessibilityHierarchyParserTests.swift‎

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,18 +1048,19 @@ final class AccessibilityHierarchyParserTests: XCTestCase {
10481048
XCTAssertTrue(elements.isEmpty, "Expected the NSNotFound count to be treated as no elements, got: \(elements)")
10491049
}
10501050

1051-
/// A `UIView` with nil `accessibilityElements` that vends elements only through the index APIs
1052-
/// must still use the subview-iteration branch. This is the single shape that actually reaches
1053-
/// the `!(self is UIView)` scoping guard: standard UIKit views either populate
1054-
/// `accessibilityElements` (e.g. `UISegmentedControl`, handled by the first check) or report
1055-
/// `NSNotFound` (handled by the sentinel guard), so the guard is otherwise defensive. The
1056-
/// branches are mutually exclusive — without the guard, the index APIs would route this view
1057-
/// into the explicit-elements branch and the subview-iteration branch would be skipped entirely,
1058-
/// surfacing the phantom element in place of the real subview. This test pins that contract so
1059-
/// the guard isn't dropped as "redundant" in a future refactor.
1060-
func testViewContainerIgnoresIndexAPIsInFavorOfSubviews() {
1051+
/// A `UIView` that exposes its children only through the index-based container APIs (nil
1052+
/// `accessibilityElements`) is resolved through that fallback, just like a non-`UIView` container
1053+
/// and just like VoiceOver consumes a `UIAccessibilityContainer`. Because the view vends elements
1054+
/// through the index APIs, those take precedence over subview traversal (the parser enters the
1055+
/// explicit-elements branch and skips the subview branch) — matching VoiceOver, which ignores the
1056+
/// subview tree once a view implements the container APIs. Standard UIKit views are unaffected:
1057+
/// they either populate `accessibilityElements` or report `NSNotFound` (see the PR description for
1058+
/// the full investigation).
1059+
func testViewContainerResolvesElementsViaIndexAPIs() {
10611060
let rootView = IndexAPIView(frame: .init(x: 0, y: 0, width: 200, height: 100))
10621061

1062+
// A subview that the subview-iteration branch would otherwise surface. Since the view vends
1063+
// elements through the index APIs, the index-vended phantom takes precedence instead.
10631064
let realSubview = UIView(frame: .init(x: 0, y: 0, width: 200, height: 40))
10641065
realSubview.isAccessibilityElement = true
10651066
realSubview.accessibilityLabel = "Real Subview"
@@ -1073,15 +1074,13 @@ final class AccessibilityHierarchyParserTests: XCTestCase {
10731074
userInterfaceIdiomProvider: TestUserInterfaceIdiomProvider(userInterfaceIdiom: .phone)
10741075
).flattenToElements().map { $0.description }
10751076

1076-
XCTAssertEqual(elements, ["Real Subview"])
1077-
XCTAssertFalse(elements.contains("Phantom Element"), "View containers must not resolve elements via the index APIs.")
1077+
XCTAssertEqual(elements, ["Phantom Element"])
10781078
}
10791079

10801080
/// End-to-end regression guard for a real `UISegmentedControl`. It vends its segments through
10811081
/// `accessibilityElements` (resolved by the first branch of `resolvedAccessibilityElements()`),
10821082
/// so it must continue to parse as a three-element series with VoiceOver-style "N of 3" context —
1083-
/// the `accessibilityElements` path must not be disturbed by the index-API fallback or its
1084-
/// `UIView` scoping.
1083+
/// the `accessibilityElements` path must not be disturbed by the index-API fallback.
10851084
func testSegmentedControlParsesAsSeries() {
10861085
let window = UIWindow(frame: .init(x: 0, y: 0, width: 320, height: 200))
10871086
let segmented = UISegmentedControl(items: ["One", "Two", "Three"])
@@ -1171,12 +1170,9 @@ private final class IndexAPIContainer: NSObject {
11711170
}
11721171

11731172
/// A `UIView` subclass with nil `accessibilityElements` that vends a uniquely-labelled "phantom"
1174-
/// element through the index-based container APIs. This is the only view shape that reaches the
1175-
/// `!(self is UIView)` guard: UIKit controls that vend internal elements (e.g. `UISegmentedControl`)
1176-
/// do so via `accessibilityElements`, which is handled before the guard, and plain views report
1177-
/// `NSNotFound`, which the sentinel guard handles. The distinct phantom label makes the regression
1178-
/// observable: if the parser ever resolved views through the index APIs, "Phantom Element" would
1179-
/// surface in place of the real subview.
1173+
/// element through the index-based container APIs — the view-shaped equivalent of `IndexAPIContainer`.
1174+
/// The distinct phantom label makes the fallback observable: when the parser resolves a view through
1175+
/// the index APIs, "Phantom Element" surfaces in place of any subview.
11801176
private final class IndexAPIView: UIView {
11811177
private lazy var phantomElement: UIAccessibilityElement = {
11821178
let element = UIAccessibilityElement(accessibilityContainer: self)

‎Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityHierarchyParser.swift‎

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -887,25 +887,19 @@ private extension NSObject {
887887
/// Returns the explicit accessibility elements exposed by this object. When
888888
/// `accessibilityElements` is set (including to an empty array) it is used directly — this covers
889889
/// UIKit containers such as `UISegmentedControl`, which populate `accessibilityElements` with
890-
/// their internal elements. When it is `nil` and the receiver is not a `UIView`, this falls back
891-
/// to the `accessibilityElementCount()` / `accessibilityElement(at:)` container APIs, mirroring
892-
/// how `UIAccessibilityContainer` consumers (including VoiceOver) resolve elements. This fallback
893-
/// is the case the method exists for: non-`UIView` `NSObject`s have no subviews to traverse, so
894-
/// without it their index-vended elements would be invisible to the parser. `UIView`s are
895-
/// deliberately excluded from the fallback so their existing subview-traversal path (with its
896-
/// grouping, sorting, and pruning) is preserved; in practice no standard UIKit view needs it
897-
/// regardless, since they either populate `accessibilityElements` or report `NSNotFound` from
898-
/// `accessibilityElementCount()`. Returns `nil` when the object does not act as an explicit
899-
/// accessibility container.
890+
/// their internal elements. When it is `nil`, this falls back to the `accessibilityElementCount()`
891+
/// / `accessibilityElement(at:)` container APIs, mirroring how `UIAccessibilityContainer`
892+
/// consumers (including VoiceOver) resolve elements — so an object whose children are exposed only
893+
/// through those APIs is captured rather than treated as a leaf. The `NSNotFound` sentinel and a
894+
/// non-positive count are filtered below, which is what keeps `UIView`s (and other objects that
895+
/// don't implement the dynamic container methods) on the caller's existing traversal path: they
896+
/// report `NSNotFound` from `accessibilityElementCount()`. Returns `nil` when the object does not
897+
/// act as an explicit accessibility container.
900898
private func resolvedAccessibilityElements() -> [NSObject]? {
901899
if let elements = accessibilityElements as? [NSObject] {
902900
return elements
903901
}
904902

905-
guard !(self is UIView) else {
906-
return nil
907-
}
908-
909903
// `accessibilityElementCount()` defaults to `NSNotFound` for objects that don't implement the
910904
// dynamic container methods (its default is undocumented, but its siblings
911905
// `accessibilityElement(at:)` and `index(ofAccessibilityElement:)` document `nil` / `NSNotFound`

0 commit comments

Comments
 (0)