Fall back to UIAccessibilityContainer index APIs when accessibilityElements is nil#335
Fall back to UIAccessibilityContainer index APIs when accessibilityElements is nil#335RoyalPineapple wants to merge 4 commits into
Conversation
…ements is nil When an object's accessibilityElements property is nil, parse its children via accessibilityElementCount() / accessibilityElement(at:) instead of dropping through to subview traversal. This mirrors how VoiceOver resolves elements exposed via the dynamic UIAccessibilityContainer APIs. A non-nil array (even empty) is still honored as-is, matching Apple's documented "alternative to implementing the dynamic methods" semantics. Emits a warning when the reported count exceeds the number of elements actually vended by index.
UIKit synthesizes accessibilityElementCount() / accessibilityElement(at:) from the subview tree when flags like shouldGroupAccessibilityChildren are set, which caused the parser to hang when recursing through containers that the existing subview-iteration path already covers. Restrict the dynamic-API fallback to receivers that are not UIViews, which preserves prior behavior for view hierarchies while still letting raw NSObject containers (e.g. custom UIAccessibilityElement-based containers) be discovered.
| } | ||
|
|
||
| let count = accessibilityElementCount() | ||
| guard count > 0 else { |
There was a problem hiding this comment.
🔴 Infinite-loop / CI hang: count > 0 doesn't exclude NSNotFound.
accessibilityElementCount()'s default return is undocumented in UIAccessibilityContainer.h (its siblings accessibilityElementAtIndex: → nil and indexOfAccessibilityElement: → NSNotFound are documented). On iOS 17/18/26 it returns NSNotFound (9_223_372_036_854_775_807) for objects that don't implement the dynamic container methods. That passes count > 0, so for index in 0 ..< count below runs ~9.2 quintillion iterations and hangs.
This is what's failing CI: all three Tuist Build jobs ran 6h0m and were canceled at the timeout. The iOS_18 log executes normally through testLargeView then goes silent for ~5h46m, hanging on the next test (testLargeViewInViewControllerThatRequiresTiling, a VC-hierarchy case where the parser reaches UIKit-internal non-UIView objects). (Note: on iOS 16.2 this method returns 0, so it won't reproduce on older local runtimes.)
Fix:
| guard count > 0 else { | |
| let count = accessibilityElementCount() | |
| guard count > 0, count != NSNotFound else { |
After this, testLargeViewInViewControllerThatRequiresTiling provides regression coverage for free.
Minor (separate line): the warning below uses print(...); the rest of this file uses os_log(..., log: parserLog, type: .error, ...) (see providedContextAsSuperview). Worth matching for consistency / to avoid stdout spam in host test processes.
accessibilityElementCount() defaults to NSNotFound for objects that don't implement the dynamic UIAccessibilityContainer methods (iOS 17+). The previous `count > 0` guard treated that sentinel as a real count, causing `for index in 0 ..< count` to iterate ~NSIntegerMax times and hang the parser (manifested as a 6h CI timeout on the Tuist Build jobs, hanging on testLargeViewInViewControllerThatRequiresTiling). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Remove the unsuppressible `print()` warning in `resolvedAccessibilityElements()`.
The count/returned-element mismatch it logged is a benign, already-handled case
(the `as? NSObject` cast intentionally tolerates nil/non-castable indices), so it
only added noise to test output. Replaced with an explanatory comment.
- Add unit coverage for the new index-API fallback:
- `testNonViewContainerResolvesElementsViaIndexAPIs`: a non-`UIView` `NSObject`
that vends elements via `accessibilityElement(at:)` is resolved.
- `testNonViewContainerWithNSNotFoundCountIsTreatedAsEmpty`: the `NSNotFound`
sentinel is treated as empty (would hang ~NSIntegerMax iterations without the
guard, so this doubles as a regression backstop).
- `testViewContainerIgnoresIndexAPIsInFavorOfSubviews`: pins the current
`!(self is UIView)` scoping contract.
- `testSegmentedControlParsesAsSeries`: end-to-end guard that a real
`UISegmentedControl` still parses as a three-element "N of 3" series.
- Rewrite the `resolvedAccessibilityElements()` doc comment. The prior rationale
("UIKit synthesizes counts from the subview tree when shouldGroupAccessibilityChildren
is set") is empirically false — plain/grouped views return `NSNotFound`. The comment
now states the real purpose: the fallback exists for non-`UIView` `NSObject`s, and the
`UIView` exclusion is a conservative scoping choice.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
accessibilityElementsproperty isnil, fall back to theaccessibilityElementCount()/accessibilityElement(at:)APIs before dropping through to subview traversal. This mirrors how VoiceOver resolves containers that vend elements via the dynamicUIAccessibilityContainermethods.nilarray is still honored as-is (including an empty array), matching Apple's documented semantics ("This can be used as an alternative to implementing the dynamic methods. default == nil").accessibilityElementCount()than it actually returns fromaccessibilityElement(at:).Test plan