Skip to content

fix(a11y): replace fatalError with assertionFailure + safe recovery#617

Merged
RoyalPineapple merged 5 commits into
mainfrom
aodawa/a11y-recover-from-fatalerrors
May 27, 2026
Merged

fix(a11y): replace fatalError with assertionFailure + safe recovery#617
RoyalPineapple merged 5 commits into
mainfrom
aodawa/a11y-recover-from-fatalerrors

Conversation

@RoyalPineapple
Copy link
Copy Markdown
Collaborator

@RoyalPineapple RoyalPineapple commented May 27, 2026

Summary

Replaces fatalError with assertionFailure + safe-recovery in five accessibility sites. The framework now surfaces programming bugs loudly in DEBUG while recovering gracefully in release rather than crashing the host app on user devices.

Behavior table

Site Trigger DEBUG Release
AccessibilityDeferral.swift L229 A ParentContainer contains >1 Receivers assertionFailure then clear all receivers clear all receivers
AccessibilityDeferral.swift L242 >1 sources share one sourceIdentifier assertionFailure then ignore (no source hidden, all stay visible to a11y) ignore
AccessibilityDeferral.swift L484 One apply() batch has mismatched updateIdentifiers assertionFailure then ignore batch ignore batch; existing content untouched
AccessibilityElement.swift L130, L147 Code writes to accessibilityFrame / accessibilityPath setters assertionFailure then no-op no-op
AccessibilityContainer.swift L118 Code writes to accessibilityElements setter assertionFailure then no-op no-op

All five sites cover programming bugs (consumer misconfiguration or framework-internal misuse), not legitimate runtime states. Crashing on a programming bug in a shipping UI library is asymmetric — small upside (loudness in development) vs large downside (production crash on user devices). The assertionFailure keeps the development-time signal; the recovery prevents the production crash.

The setter pattern matches the existing one at ReceiverContainerView.accessibilityPath setter (L325) which already used assertionFailure + no-op. The deferral recovery patterns all fail safe — when there's ambiguity (multiple receivers, duplicate source identifiers, mismatched batch) no content gets applied rather than guessing.

Why no tests

The L484 recovery path would be the only candidate, but XCTest traps on assertionFailure in DEBUG builds — testing the release-mode hedge would require either an injectable assert wrapper (more infrastructure) or release-mode-only tests (CI complexity). The assertionFailure itself is the regression net: any future change that re-introduces the bug class will trip the assert in dev.

Test plan

  • BlueprintUIAccessibilityCore and BlueprintUICommonControls schemes build cleanly on iOS Simulator.
  • Internal Bazel CI green.
  • No regression in AccessibilityDeferralTests or AccessibilityCompositionTests.

Follow-ups (out of scope here)

  • BlueprintUIAccessibilityCoreTests is not wired into Package.swift today (only Bazel builds it). Also BlueprintUITests has an undeclared dependency on BlueprintUICommonControls, which currently breaks swift test for external SwiftPM consumers. Worth fixing together.
  • Strategy report REC-002 proposes adding dedicated unit tests for the eight untested a11y wrappers.

🤖 Generated with Claude Code

RoyalPineapple and others added 5 commits May 27, 2026 15:33
…ead of crashing

Replace fatalError with safe-recovery paths in two AccessibilityDeferral
sites and three setter-override sites. The framework now no-ops or falls
back to safe behavior when a consumer misconfigures something rather
than crashing the host app:

- AccessibilityDeferral.swift L242: duplicate sources for one identifier
  now take the first match (rest stay exposed) instead of fatalError.
- AccessibilityDeferral.swift L484: a batch with mismatched
  updateIdentifiers now falls through to replaceContent instead of
  fatalError, treating the batch as a fresh pass rather than a merge.
- AccessibilityElement.swift L130/L147 and AccessibilityContainer.swift
  L118: setters on read-only computed properties now assertionFailure +
  no-op instead of fatalError, matching the existing pattern at
  ReceiverContainerView.accessibilityPath setter.

Also adds a recovery test in AccessibilityDeferralTests covering the
mismatched-updateIdentifier path.

Note: BlueprintUIAccessibilityCoreTests is not wired into Package.swift
(only Bazel currently builds it). The new test runs under Square's
internal CI but won't run via \`swift test\` until that's addressed in
a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The mismatched-updateIdentifier guard in Receiver.apply previously
replaced existing content with the malformed batch. We cannot assume
the malformed batch is fresher than what's already applied, so leave
existing content untouched — the next legitimate broker pass will
overwrite it.

Also drops two comments that referenced pre-change behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The deferral recovery sites cover programming bugs in consumer code,
not legitimate runtime states. Add assertionFailure at all three so
they surface loudly in DEBUG while release builds still recover
gracefully. Drop the malformed-batch recovery test since the new
assertion would trip it in DEBUG and the assertion itself is the
regression net.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The assertionFailure messages already describe the constraint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When multiple sources share an identifier, ambiguity is the bug —
picking the first match depends on subview ordering and would silently
hide one source. Treat as no match instead: no source gets hidden, no
content gets consolidated, all duplicates stay visible to assistive
tech. Matches the >1-receiver site which also fails safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@RoyalPineapple RoyalPineapple changed the title fix(a11y): recover from misconfigured deferral and setter misuse instead of crashing fix(a11y): replace fatalError with assertionFailure + safe recovery May 27, 2026
@RoyalPineapple RoyalPineapple marked this pull request as ready for review May 27, 2026 16:01
@RoyalPineapple RoyalPineapple requested a review from a team as a code owner May 27, 2026 16:01
Copy link
Copy Markdown
Contributor

@johnnewman-square johnnewman-square left a comment

Choose a reason for hiding this comment

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

This looks good - each updated site is totally recoverable, like the description mentions 💯

@RoyalPineapple RoyalPineapple merged commit 9a4e93e into main May 27, 2026
11 checks passed
@RoyalPineapple RoyalPineapple deleted the aodawa/a11y-recover-from-fatalerrors branch May 27, 2026 16:31
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.

3 participants