Skip to content

Fall back to UIAccessibilityContainer index APIs when accessibilityElements is nil - #344

Open
RoyalPineapple wants to merge 1 commit into
mainfrom
RoyalPineapple/remove-uiview-index-api-guard
Open

RoyalPineapple wants to merge 1 commit into
mainfrom
RoyalPineapple/remove-uiview-index-api-guard

Conversation

@RoyalPineapple

@RoyalPineapple RoyalPineapple commented Jun 4, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Falls back to the UIAccessibilityContainer index APIs (accessibilityElementCount() / accessibilityElement(at:)) when accessibilityElements returns nil.

Some containers vend their children only through the index-based container APIs, returning nil from accessibilityElements. The parser previously treated these as having no explicit elements and fell through to subview traversal, missing the container-vended children. This change mirrors the behavior of UIKit's own accessibility tree walker, which uses the index APIs for any object that reports a valid count — including UIViews.

How it works

resolvedAccessibilityElements() checks accessibilityElements first. If nil, it iterates accessibilityElement(at:) up to accessibilityElementCount(). Objects that report NSNotFound from accessibilityElementCount() (the default) fall through unchanged — their existing traversal path (subview iteration with grouping, sorting, and pruning) is preserved.

In practice, no standard UIKit UIView uses the index API without also setting accessibilityElements — UITableView, UICollectionView, UIScrollView, etc. all report either NSNotFound or count=0 with nil elements. The fallback primarily captures non-UIView containers (e.g. UIAccessibilityElement subclasses) that implement the dynamic container methods.

Validation

Validated against _accessibilityLeafDescendantsWithOptions: (UIKit's internal tree walker) with default options, VoiceOver options, and scanner groups mode. All three confirm the index API takes precedence over subview traversal when implemented.

Changes

  • Add resolvedAccessibilityElements() on NSObject — tries accessibilityElements, falls back to index APIs, filters NSNotFound and non-positive count sentinels
  • Tests for index-API fallback (both UIView and non-UIView), NSNotFound sentinel, zero-count, UISegmentedControl end-to-end, and UIView container resolution

@RoyalPineapple
RoyalPineapple changed the base branch from RoyalPineapple/check-a11y-element-index-apis to main June 26, 2026 09:38
@RoyalPineapple RoyalPineapple changed the title Remove UIView scoping from the index-API fallback Fall back to UIAccessibilityContainer index APIs when accessibilityElements is nil Jun 26, 2026
@RoyalPineapple
RoyalPineapple force-pushed the RoyalPineapple/remove-uiview-index-api-guard branch from 969bf79 to 535b5fd Compare June 28, 2026 10:20
@RoyalPineapple
RoyalPineapple marked this pull request as ready for review June 28, 2026 11:07
@RoyalPineapple
RoyalPineapple marked this pull request as draft June 28, 2026 15:00
@RoyalPineapple
RoyalPineapple force-pushed the RoyalPineapple/remove-uiview-index-api-guard branch from 0176ab5 to 51851ab Compare July 7, 2026 13:19
@RoyalPineapple
RoyalPineapple marked this pull request as ready for review July 7, 2026 13:48
…ements is nil

When `accessibilityElements` is nil, fall back to the
`accessibilityElementCount()` / `accessibilityElement(at:)` container
APIs so that objects whose children are exposed only through those APIs
are captured rather than treated as leaves. This applies to all objects
including UIViews, matching the behavior of UIKit's own accessibility
tree walker.

Guards against the NSNotFound sentinel from `accessibilityElementCount()`
to avoid iterating ~NSIntegerMax times, and tolerates nil returns from
`accessibilityElement(at:)` for individual indices.
@RoyalPineapple
RoyalPineapple force-pushed the RoyalPineapple/remove-uiview-index-api-guard branch from 51851ab to 64659c0 Compare July 7, 2026 13:59

@robmaceachern robmaceachern left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The bot generated some feedback that might be worth considering but lgtm.

elements.append(element)
}
}
return elements.isEmpty ? nil : elements

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

blocker: Preserve explicit-container precedence when all indexed slots are invalid

A positive count selects the dynamic container path, but filtering every slot to an empty array turns it back into nil. For a UIView, that switches to subview traversal at line 854, so native subviews leak into the snapshot even though the index APIs should take precedence; it also differs from an explicit accessibilityElements = []. Return the possibly empty elements array after a valid positive count, and cover count = 1 with a nil slot plus an accessible real subview to lock this precedence.


This response was drafted with AI assistance.

// _UIInheritedView inside _UIFloatingBarContainerView) use zero-frame wrappers whose children overflow and
// are visible. Pruning those hides real accessible content such as the UISearchBarTextField rendered by
// .searchable().
let explicitAccessibilityElements = resolvedAccessibilityElements()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

suggestion: Prune hidden and transparent views before resolving dynamic elements

This call now runs before the existing isHidden / alpha short-circuit. A hidden custom view with nil accessibilityElements and a finite count will enumerate every index even though line 824 immediately discards it; the base implementation never evaluated its elements because the visibility condition short-circuited first. Split the visibility/alpha guard ahead of resolution, leaving the zero-frame decision after it, and add a call-count regression so hidden reusable views cannot introduce unexpected work or side effects.


This response was drafted with AI assistance.

}

var elements: [NSObject] = []
elements.reserveCapacity(count)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

suggestion: Bound implausible positive counts before reserving and iterating

NSNotFound is only one invalid or stale value. A container returning Int.max - 1, or another implausibly large finite count, still reaches reserveCapacity and can terminate the host or spend unbounded time dispatching accessibilityElement(at:). Since this parser runs in the client process and already treats the sentinel defensively, reject and log an implementation-defined safety limit before allocation/iteration. A fake container can cover this by asserting that indexed lookup is never called.


This response was drafted with AI assistance.

// `accessibilityElement(at:)` may return `nil` (or a non-`NSObject`) for individual indices even
// when `accessibilityElementCount()` reports a larger count; those are skipped rather than treated
// as a hard error, mirroring how a consumer iterating the container would tolerate gaps.
if let element = accessibilityElement(at: index) as? NSObject {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

blocker: Detect cycles on the active traversal path

An index container can vend itself or an ancestor; once appended here, line 840 recursively enters that object without termination. This is newly reachable for dynamic-only containers, which were not traversed while accessibilityElements was nil before this change. Thread active ancestor identities through recursiveAccessibilityHierarchy and skip/log back-edges only (rather than globally deduplicating, so legitimate repeated elements remain). Please cover both a self-reference and a two-container cycle.


This response was drafted with AI assistance.

XCTAssertTrue(elements.isEmpty, "Expected the NSNotFound count to be treated as no elements, got: \(elements)")
}

func testContainerWithZeroCountIsTreatedAsEmpty() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

suggestion: Make the zero-count test distinguish the new traversal behavior

This test also passes on the base revision: a non-UIView object with no explicit elements produced no nodes before the fallback. To guard the intended compatibility branch, use an index-backed UIView reporting zero with an accessible subview and assert that the subview is still traversed. Otherwise resolvedAccessibilityElements() could incorrectly return [] for zero and this test would remain green.


This response was drafted with AI assistance.

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.

2 participants