Skip to content

Commit 969bf79

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 4a5eb6c commit 969bf79

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
@@ -1147,18 +1147,19 @@ final class AccessibilityHierarchyParserTests: XCTestCase {
11471147
XCTAssertTrue(elements.isEmpty, "Expected the NSNotFound count to be treated as no elements, got: \(elements)")
11481148
}
11491149

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

1161+
// A subview that the subview-iteration branch would otherwise surface. Since the view vends
1162+
// elements through the index APIs, the index-vended phantom takes precedence instead.
11621163
let realSubview = UIView(frame: .init(x: 0, y: 0, width: 200, height: 40))
11631164
realSubview.isAccessibilityElement = true
11641165
realSubview.accessibilityLabel = "Real Subview"
@@ -1172,15 +1173,13 @@ final class AccessibilityHierarchyParserTests: XCTestCase {
11721173
userInterfaceIdiomProvider: TestUserInterfaceIdiomProvider(userInterfaceIdiom: .phone)
11731174
).flattenToElements().map { $0.description }
11741175

1175-
XCTAssertEqual(elements, ["Real Subview"])
1176-
XCTAssertFalse(elements.contains("Phantom Element"), "View containers must not resolve elements via the index APIs.")
1176+
XCTAssertEqual(elements, ["Phantom Element"])
11771177
}
11781178

11791179
/// End-to-end regression guard for a real `UISegmentedControl`. It vends its segments through
11801180
/// `accessibilityElements` (resolved by the first branch of `resolvedAccessibilityElements()`),
11811181
/// so it must continue to parse as a three-element series with VoiceOver-style "N of 3" context —
1182-
/// the `accessibilityElements` path must not be disturbed by the index-API fallback or its
1183-
/// `UIView` scoping.
1182+
/// the `accessibilityElements` path must not be disturbed by the index-API fallback.
11841183
func testSegmentedControlParsesAsSeries() {
11851184
let window = UIWindow(frame: .init(x: 0, y: 0, width: 320, height: 200))
11861185
let segmented = UISegmentedControl(items: ["One", "Two", "Three"])
@@ -1270,12 +1269,9 @@ private final class IndexAPIContainer: NSObject {
12701269
}
12711270

12721271
/// A `UIView` subclass with nil `accessibilityElements` that vends a uniquely-labelled "phantom"
1273-
/// element through the index-based container APIs. This is the only view shape that reaches the
1274-
/// `!(self is UIView)` guard: UIKit controls that vend internal elements (e.g. `UISegmentedControl`)
1275-
/// do so via `accessibilityElements`, which is handled before the guard, and plain views report
1276-
/// `NSNotFound`, which the sentinel guard handles. The distinct phantom label makes the regression
1277-
/// observable: if the parser ever resolved views through the index APIs, "Phantom Element" would
1278-
/// surface in place of the real subview.
1272+
/// element through the index-based container APIs — the view-shaped equivalent of `IndexAPIContainer`.
1273+
/// The distinct phantom label makes the fallback observable: when the parser resolves a view through
1274+
/// the index APIs, "Phantom Element" surfaces in place of any subview.
12791275
private final class IndexAPIView: UIView {
12801276
private lazy var phantomElement: UIAccessibilityElement = {
12811277
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
@@ -783,25 +783,19 @@ private extension NSObject {
783783
/// Returns the explicit accessibility elements exposed by this object. When
784784
/// `accessibilityElements` is set (including to an empty array) it is used directly — this covers
785785
/// UIKit containers such as `UISegmentedControl`, which populate `accessibilityElements` with
786-
/// their internal elements. When it is `nil` and the receiver is not a `UIView`, this falls back
787-
/// to the `accessibilityElementCount()` / `accessibilityElement(at:)` container APIs, mirroring
788-
/// how `UIAccessibilityContainer` consumers (including VoiceOver) resolve elements. This fallback
789-
/// is the case the method exists for: non-`UIView` `NSObject`s have no subviews to traverse, so
790-
/// without it their index-vended elements would be invisible to the parser. `UIView`s are
791-
/// deliberately excluded from the fallback so their existing subview-traversal path (with its
792-
/// grouping, sorting, and pruning) is preserved; in practice no standard UIKit view needs it
793-
/// regardless, since they either populate `accessibilityElements` or report `NSNotFound` from
794-
/// `accessibilityElementCount()`. Returns `nil` when the object does not act as an explicit
795-
/// accessibility container.
786+
/// their internal elements. When it is `nil`, this falls back to the `accessibilityElementCount()`
787+
/// / `accessibilityElement(at:)` container APIs, mirroring how `UIAccessibilityContainer`
788+
/// consumers (including VoiceOver) resolve elements — so an object whose children are exposed only
789+
/// through those APIs is captured rather than treated as a leaf. The `NSNotFound` sentinel and a
790+
/// non-positive count are filtered below, which is what keeps `UIView`s (and other objects that
791+
/// don't implement the dynamic container methods) on the caller's existing traversal path: they
792+
/// report `NSNotFound` from `accessibilityElementCount()`. Returns `nil` when the object does not
793+
/// act as an explicit accessibility container.
796794
private func resolvedAccessibilityElements() -> [NSObject]? {
797795
if let elements = accessibilityElements as? [NSObject] {
798796
return elements
799797
}
800798

801-
guard !(self is UIView) else {
802-
return nil
803-
}
804-
805799
// `accessibilityElementCount()` defaults to `NSNotFound` for objects that don't implement the
806800
// dynamic container methods (its default is undocumented, but its siblings
807801
// `accessibilityElement(at:)` and `index(ofAccessibilityElement:)` document `nil` / `NSNotFound`

0 commit comments

Comments
 (0)