Skip to content

draft(ios-qa): SwiftUI a11y walker cleanup + GstackProbe + docs (iOS 26 NOT fixed)#1755

Draft
sternryan wants to merge 1 commit into
garrytan:mainfrom
sternryan:fix/ios-qa-swiftui-a11y-walker
Draft

draft(ios-qa): SwiftUI a11y walker cleanup + GstackProbe + docs (iOS 26 NOT fixed)#1755
sternryan wants to merge 1 commit into
garrytan:mainfrom
sternryan:fix/ios-qa-swiftui-a11y-walker

Conversation

@sternryan

@sternryan sternryan commented May 27, 2026

Copy link
Copy Markdown
Contributor

Draft — held back for honest scoping after live-device validation. Walker improvements verified to compile and not regress; the on-device SwiftUI tree-walking claim does NOT hold on iOS 26.3.1 (see validation comment). PR rewritten to reflect what's actually being proposed.

What this PR is now

Three low-risk, additive changes plus an honest docs page:

  1. Walker cleanup in ios-qa/templates/Bridges.swift.template — refactor that should be net-positive on every iOS version, no behavior regression observed:

    • Extract emitAXChild() helper (removes duplicated synthetic-element extraction).
    • Always run the indexed accessor path when accessibilityElements returns nil OR empty array (the existing template only fell through on nil — SwiftUI hosting views can return empty array with a non-zero accessibilityElementCount(), which was getting silently dropped).
    • Filter pure-container synthetic nodes (zero label/ident/value/traits) — reduces noise without dropping useful entries.
    • Tag synthetic entries with "source": "ax-synthetic" for downstream agent disambiguation.
    • Add UIAccessibility.post(notification: .layoutChanged, argument: nil) before walking. Documented public API. Note: likely a working trick on iOS 17-18 per related Swift-Forums + swift-agentation prior art, but does NOT trigger SwiftUI AX tree materialization on iOS 26.3.1 per device validation below.
  2. .gstackProbe(_:) SwiftUI ViewModifier (new public API in template) — opt-in escape hatch. Apps that need agent visibility on views the AX tree refuses to surface call .gstackProbe("dashboard.captureButton") and the walker merges (id, frame, label) into the /elements snapshot from GstackProbeRegistry. Uses GeometryReader { .global } + PreferenceKey to keep frames current without polling. Pure public API — no private selectors. Not validated on device in this PR — requires explicit per-view instrumentation that wasn't part of the validation app.

  3. New docs page ios-qa/docs/swiftui-accessibility.md — explains the underlying constraint (SwiftUI doesn't always create backing UIViews; AX tree is lazy + VoiceOver-gated), what this PR fixes, what it does NOT fix on iOS 26, and when to reach for .gstackProbe(_:) vs vision-based coordinate tapping.

What this PR is NOT

A general fix for the SwiftUI accessibility-walker problem on iOS 26. That problem appears to require either private XCTest APIs (XCAXClient_iOS, not available in an in-process DEBUG bridge), an actual VoiceOver-running session, or vision-based positioning. The docs page now says this plainly.

Validation

  • iPhone 12 Pro on iOS 26.3.1 (live device): ported the walker changes into a real SwiftUI app (Principal's Ear's Apps/iOS/DebugBridge/Bridges.swift), hit GET /elements against a Dashboard view with declared .accessibilityIdentifier(...) modifiers. Returns 3 elements (same as pre-PR). The walker cleanup is a no-op regression-wise; the SwiftUI tree-walking claim doesn't hold on this iOS version. Full data in comment thread.
  • Type-check pass: clean against iOS 16/17 SDKs (the Package.swift.template deployment floor) and Swift 6 strict concurrency.
  • .gstackProbe(_:) modifier: untested on device — requires app developer to instrument views with it before there's anything to validate.

Sources consulted

Suggested merge disposition

Three reasonable paths, in increasing order of conservatism:

  • A) Merge as-is. Walker cleanup + .gstackProbe(_:) modifier + docs are all useful additions; the docs page is explicit about iOS 26 limitations.
  • B) Merge walker cleanup + docs only, drop .gstackProbe(_:) until someone validates it on device.
  • C) Close as superseded by docs-only PR. I can spin a separate docs-only follow-up that just adds swiftui-accessibility.md + filters the noise-element cleanup, no API surface changes.

Author's recommendation: B — gets the cleanup + the honest docs in front of users, defers the new API until it has device evidence.

Tested against: iPhone 12 Pro, iOS 26.3.1, Xcode 26.0.1, Swift 6.2.

The /elements walker returned only top-level `_UIHostingView` /
`HostingView` shells on SwiftUI screens — three entries for a full
Dashboard, all with empty identifier/label and a 0,0,390,844 frame.
SwiftUI vends its AX leaves through the indexed accessor
(`accessibilityElementCount` + `accessibilityElement(at:)`) on the
hosting container, not through `accessibilityElements` (which returns
nil OR an empty array). The previous walker only fell through to the
indexed path on nil, not on empty — so SwiftUI leaves were silently
dropped.

Fix is three coordinated changes to ElementsBridgeImpl plus an opt-in
escape hatch:

1. Force materialization via `UIAccessibility.post(.layoutChanged, …)`
   before the walk. Public API, no-op when VoiceOver already populated
   the tree, doesn't speak.
2. Always run the indexed accessor when `accessibilityElements` is nil
   OR empty. This is the actual SwiftUI path.
3. Read identifier/label/value/traits/frame from synthetic AX elements
   via KVC over the documented `UIAccessibility` informal protocol
   property names — safe and version-independent across the private
   element classes (`_AXSnapshotElement`, `_UIAccessibilityElementMockView`, …).
4. New `.gstackProbe("identifier")` SwiftUI ViewModifier + thread-safe
   `GstackProbeRegistry` for views the AX tree refuses to surface
   (intentionally hidden, custom Canvas, decorative Shape stacks). Probe
   sets `.accessibilityIdentifier(_:)` AND registers (id, frame) in the
   registry; the walker merges these in as synthetic entries tagged
   `"source":"gstack-probe"`.

Also filters empty container synthetic nodes (no label/id/value/traits)
that were clogging the output before.

Docs in `ios-qa/docs/swiftui-accessibility.md` cover the root cause,
what the fix does/doesn't address, and when to reach for `.gstackProbe`
vs vision-based coordinate tapping.

Typechecks clean against iOS 16 + iOS 17 SDKs, Swift 6 strict
concurrency. Live-device validation pending — PR author will port to
downstream app + report new element count.
@sternryan

Copy link
Copy Markdown
Contributor Author

Downstream validation — iPhone 12 Pro on iOS 26.3.1

Ported this PR's Bridges.swift.template changes (force UIAccessibility.post(.layoutChanged) + always-run indexed AX accessor + dual-path emission + emitAXChild helper) into a real SwiftUI app's DebugBridge (Principal's Ear), redeployed to a physical iPhone 12 Pro on iOS 26.3.1, and ran GET /elements against a freshly-loaded Dashboard view with declared identifiers (dashboard.captureButton, dashboard.settingsButton, etc.).

Result: still 3 elements, identical to the pre-PR behavior.

TOTAL: 3 | with-identifier: 0 | with-label: 0 | ax-synthetic: 0

  _UIHostingView<ModifiedContent<AnyView, RootModifi...   ident=''  label=''  frame=(0,0,390x844)
  HostingView                                             ident=''  label=''  frame=(0,0,390x844)
  FloatingBarHostingView<FloatingBarContainer>            ident=''  label=''  frame=(0,0,390x844)

Tried with a screenshot-warmup pass before the elements query (in case drawHierarchy would trigger AX hookup) — no change. Tried with a tap delivered between two element queries — no change.

The walker emits these three only because they hit the className.contains("Hosting") || className.contains("SwiftUI") branch. Recursing through subviews of each hosting view continues to find only non-AX _UIGraphicsView-style internal shells, and accessibilityElementCount() returns 0 on all of them post-.layoutChanged-post.

Diagnosis: on iOS 26.x SDK, UIAccessibility.post(.layoutChanged) no longer triggers SwiftUI AX tree materialization for in-process queries — this was likely a working trick on iOS 17-18 (which is what the related Medium articles + swift-agentation referenced) but Apple appears to have made the AX subsystem stricter about VoiceOver-being-actually-running as the gate.

Recommendation:

  • The GstackProbe ViewModifier path in this PR is still useful — it's a documented, opt-in escape hatch, and apps that want full agent visibility on iOS 26 will need it for any view that doesn't expose AX naturally.
  • The walker improvements (always trying indexed accessor, filtering empty synthetic nodes, emitAXChild helper) are no-cost wins that benefit iOS 17/18 users and are sound code anyway — worth keeping.
  • The docs page should add an iOS 26 reality check: the AX-tree-walker path is degraded on this version; vision-based positioning (parse the screenshot, tap by coordinate) is the supported approach for unmodified SwiftUI apps. The .gstackProbe(_:) modifier is the path forward for apps where the developer can instrument views.
  • If you want to chase the iOS 26 regression, the next thing to try is the XCTest private-API path (XCAXClient_iOS) — but that requires running in a test target, not an in-process DEBUG bridge. Likely a non-starter for this skill.

Tested against: iPhone 12 Pro, iOS 26.3.1, Xcode 26.0.1, Swift 6.2, app target uses iOS 26 deployment min, daemon at sternryan/gstack@bea2468 with the patched 24h cache TTL applied locally (PR #1756).

@sternryan sternryan marked this pull request as draft May 27, 2026 18:46
@sternryan sternryan changed the title fix(ios-qa): walker now surfaces SwiftUI elements, not just hosting shells draft(ios-qa): SwiftUI a11y walker cleanup + GstackProbe + docs (iOS 26 NOT fixed) May 27, 2026
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