Skip to content

fix: restore accessibilityViewIsModal after nested modal dismiss#8

Open
RoyalPineapple wants to merge 3 commits intomainfrom
fix/accessibility-view-is-modal-nested-dismiss
Open

fix: restore accessibilityViewIsModal after nested modal dismiss#8
RoyalPineapple wants to merge 3 commits intomainfrom
fix/accessibility-view-is-modal-nested-dismiss

Conversation

@RoyalPineapple
Copy link
Copy Markdown
Contributor

@RoyalPineapple RoyalPineapple commented Mar 31, 2026

Summary

When a modal is presented on top of another modal and then dismissed, the remaining modal's ContainerView is left with accessibilityViewIsModal = false. This causes VoiceOver to expose elements from both the modal and the underlying parent screen — doubling every element visible to assistive technology.

Before fix — after dismissing a nested modal:

  • accessibilityViewIsModal on the remaining modal's ContainerView is false
  • Parent screen elements leak through the modal mask
  • VoiceOver users see duplicated elements with no way to distinguish layers

After fix:

  • accessibilityViewIsModal is correctly restored to true
  • Only the top modal's elements are visible to VoiceOver

Root cause

Three issues in ModalPresentationViewController:

1. Flag assignment included exiting presentations

updateAccessibilityViewIsModal() used allPresentations.map(\.containerView) which includes presentations in .pendingExit and .exiting states. The exiting presentation's ContainerView becomes .last and receives the flag, while the remaining presentation's container is set to false.

2. Early-return guard prevented re-evaluation

guard oldTopLayer != topLayer else {
    return // Flag assignment was below this — never reached
}

When the nested modal dismisses, the top accessibility layer returns to the same presentation that was on top before. The guard bails, skipping the flag assignment.

3. remove(presentation:) didn't re-evaluate the flag

After the exit animation completes, remove(presentation:) removes the presentation from allPresentations but never calls updateAccessibilityViewIsModal(). The remaining container keeps its false value permanently.

Fix

  1. Move the flag assignment above the early-return guard so it runs unconditionally
  2. Use presentations(includeExiting: false) to exclude exiting presentations
  3. Call updateAccessibilityViewIsModal() from remove(presentation:) to restore the flag after removal

Commits

  1. test: Adds a failing test that reproduces the bug — presents modal A, presents modal B on top, dismisses B, asserts accessibilityViewIsModal is true on A's ContainerView. Fails without the fix.
  2. fix: Makes the test pass. 46/46 unit tests green.

Reproduction

  1. Present a full-screen modal
  2. From within that modal, present a second modal (any style)
  3. Dismiss the second modal
  4. The first modal's ContainerView now has accessibilityViewIsModal = false
  5. VoiceOver can navigate to elements behind the modal that should be hidden

Test plan

  • Test fails without fix (accessibilityViewIsModal = false)
  • Test passes with fix (accessibilityViewIsModal = true)
  • Full unit test suite passes (46/46)
  • VoiceOver manual verification after nested dismiss

🤖 Generated with Claude Code

@RoyalPineapple RoyalPineapple force-pushed the fix/accessibility-view-is-modal-nested-dismiss branch 2 times, most recently from 9b5608c to f0857c0 Compare March 31, 2026 20:57
@RoyalPineapple RoyalPineapple marked this pull request as ready for review March 31, 2026 21:15
@RoyalPineapple RoyalPineapple requested a review from a team as a code owner March 31, 2026 21:15
RoyalPineapple and others added 2 commits April 2, 2026 18:47
Demonstrates that when a modal is presented on top of another modal and
then dismissed, the remaining modal's ContainerView is left with
accessibilityViewIsModal = false. This causes VoiceOver and
accessibility parsers to expose elements from both the modal and the
parent screen simultaneously.

This test currently fails — the fix follows in the next commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three issues caused the remaining modal's ContainerView to be left with
accessibilityViewIsModal = false after a nested modal dismisses:

1. The flag assignment used allPresentations (includes exiting
   presentations) instead of presentations(includeExiting: false).

2. The flag assignment was gated behind an early-return guard that
   bails when the top layer hasn't changed — which is exactly the case
   when dismissing back to the same top modal.

3. remove(presentation:) never re-evaluated the flag after removing
   the exiting presentation from allPresentations.

Fix: move the flag assignment above the guard so it runs
unconditionally, use the filtered presentation list, and call
updateAccessibilityViewIsModal() from remove(presentation:).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@RoyalPineapple RoyalPineapple force-pushed the fix/accessibility-view-is-modal-nested-dismiss branch from f0857c0 to 7ffe995 Compare April 2, 2026 16:47
Co-Authored-By: Claude Opus 4.6 (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.

2 participants