Skip to content

Fall back to UIAccessibilityContainer index APIs when accessibilityElements is nil#335

Draft
RoyalPineapple wants to merge 4 commits into
mainfrom
RoyalPineapple/check-a11y-element-index-apis
Draft

Fall back to UIAccessibilityContainer index APIs when accessibilityElements is nil#335
RoyalPineapple wants to merge 4 commits into
mainfrom
RoyalPineapple/check-a11y-element-index-apis

Conversation

@RoyalPineapple
Copy link
Copy Markdown
Collaborator

Summary

  • When parsing an object whose accessibilityElements property is nil, fall back to the accessibilityElementCount() / accessibilityElement(at:) APIs before dropping through to subview traversal. This mirrors how VoiceOver resolves containers that vend elements via the dynamic UIAccessibilityContainer methods.
  • A non-nil array 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").
  • Logs a warning when a container reports more elements via accessibilityElementCount() than it actually returns from accessibilityElement(at:).

Test plan

  • CI green
  • Existing snapshot tests still pass

…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 {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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:

Suggested change
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.

RoyalPineapple and others added 2 commits May 29, 2026 21:16
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant