From 3477eee640f889d20c456e17ba9f19fae862d2c4 Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Wed, 27 May 2026 15:33:32 +0200 Subject: [PATCH 1/5] fix(a11y): recover from misconfigured deferral and setter misuse instead 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) --- .../Sources/AccessibilityDeferral.swift | 12 +++++-- .../Tests/AccessibilityDeferralTests.swift | 31 +++++++++++++++++++ .../AccessibilityContainer.swift | 2 +- .../Accessibility/AccessibilityElement.swift | 4 +-- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift index 0935b9a4d..a2aa98899 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift @@ -227,7 +227,8 @@ extension AccessibilityDeferral { guard receivers.count <= 1 else { - // We cannot reasonably determine which receiver to apply the content to. + // A ParentContainer must contain at most one Receiver. If a consumer wires up + // more than one we cannot pick safely, so fail safe by clearing every receiver. receivers.forEach { $0.apply(content: nil, frameProvider: nil) } return } @@ -239,7 +240,8 @@ extension AccessibilityDeferral { let deferredContent = contents?.map { content in var updated = content let matches = sources.filter { $0.contentIdentifier == content.sourceIdentifier } - guard matches.count <= 1 else { fatalError("Found multiple deferral sources with the same identifier. \(matches)") } + // If multiple sources share an identifier we cannot pick safely; first wins + // and the rest stay visible to assistive tech rather than crashing. let match = matches.first match?.accessibilityElementsHidden = true updated.inheritedAccessibility = match?.accessibility @@ -481,7 +483,11 @@ extension AccessibilityDeferral.Receiver { ) { guard let content, !content.isEmpty else { replaceContent([]); return } guard let updateID = content.first?.updateIdentifier, content.allSatisfy({ $0.updateIdentifier == updateID }) else { - fatalError("Cannot merge deferral content as update identifiers do not match.") + // Entries in one batch should share an updateIdentifier within a layout pass. + // If they don't we cannot safely merge stale content — treat as a fresh replace. + replaceContent(content) + updateDeferredAccessibility(frameProvider: frameProvider) + return } let lastUpdateID = deferredAccessibilityContent?.first?.updateIdentifier diff --git a/BlueprintUIAccessibilityCore/Tests/AccessibilityDeferralTests.swift b/BlueprintUIAccessibilityCore/Tests/AccessibilityDeferralTests.swift index c7d1c455c..e798f6dc4 100644 --- a/BlueprintUIAccessibilityCore/Tests/AccessibilityDeferralTests.swift +++ b/BlueprintUIAccessibilityCore/Tests/AccessibilityDeferralTests.swift @@ -132,6 +132,37 @@ class AccessibilityDeferralTests: XCTestCase { XCTAssertEqual(receiver.accessibilityCustomActions?.first?.name, "Test Action") } + // MARK: - Recovery from malformed input + + func test_apply_recovers_from_mismatched_updateIdentifiers() { + let receiver = TestReceiver() + + // Seed the receiver with valid content sharing one updateID. + let firstUpdateID = UUID() + var seed = AccessibilityDeferral.Content(kind: .inherited(), identifier: "source1") + seed.updateIdentifier = firstUpdateID + seed.inheritedAccessibility = makeRepresentation(label: "Seed") + receiver.apply(content: [seed], frameProvider: nil) + XCTAssertEqual(receiver.deferredAccessibilityContent?.count, 1) + + // Now hand it a malformed batch with two different updateIDs. + var malformedA = AccessibilityDeferral.Content(kind: .inherited(), identifier: "sourceA") + malformedA.updateIdentifier = UUID() + malformedA.inheritedAccessibility = makeRepresentation(label: "Recovered A") + + var malformedB = AccessibilityDeferral.Content(kind: .inherited(), identifier: "sourceB") + malformedB.updateIdentifier = UUID() + malformedB.inheritedAccessibility = makeRepresentation(label: "Recovered B") + + // Previously this crashed via fatalError. Now it should recover by replacing rather than merging. + receiver.apply(content: [malformedA, malformedB], frameProvider: nil) + + // Content should be the malformed batch (replaced), not the seed. + XCTAssertEqual(receiver.deferredAccessibilityContent?.count, 2) + XCTAssertEqual(receiver.deferredAccessibilityContent?.first?.inheritedAccessibility?.label, "Recovered A") + XCTAssertEqual(receiver.deferredAccessibilityContent?.last?.inheritedAccessibility?.label, "Recovered B") + } + // MARK: - Content customContent generation func test_content_inherited_customContent() { diff --git a/BlueprintUICommonControls/Sources/Accessibility/AccessibilityContainer.swift b/BlueprintUICommonControls/Sources/Accessibility/AccessibilityContainer.swift index 13e68865d..de855fb09 100644 --- a/BlueprintUICommonControls/Sources/Accessibility/AccessibilityContainer.swift +++ b/BlueprintUICommonControls/Sources/Accessibility/AccessibilityContainer.swift @@ -115,7 +115,7 @@ extension AccessibilityContainer { layoutDirection: layoutDirection ) } - set { fatalError("This property is not settable") } + set { assertionFailure("accessibilityElements is not settable on AccessibilityContainerView") } } } } diff --git a/BlueprintUICommonControls/Sources/Accessibility/AccessibilityElement.swift b/BlueprintUICommonControls/Sources/Accessibility/AccessibilityElement.swift index d2128d71e..5565dd700 100644 --- a/BlueprintUICommonControls/Sources/Accessibility/AccessibilityElement.swift +++ b/BlueprintUICommonControls/Sources/Accessibility/AccessibilityElement.swift @@ -127,7 +127,7 @@ public struct AccessibilityElement: Element { } set { - fatalError("accessibilityFrame is not settable on AccessibilityView") + assertionFailure("accessibilityFrame is not settable on AccessibilityView") } } @@ -144,7 +144,7 @@ public struct AccessibilityElement: Element { } set { - fatalError("accessibilityPath is not settable on AccessibilityView") + assertionFailure("accessibilityPath is not settable on AccessibilityView") } } From 58cac188ff43c3e0ac65e80013dcfd5312945c6c Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Wed, 27 May 2026 15:39:05 +0200 Subject: [PATCH 2/5] fix(a11y): ignore malformed deferral batches instead of trusting them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Sources/AccessibilityDeferral.swift | 8 ++++---- .../Tests/AccessibilityDeferralTests.swift | 19 +++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift index a2aa98899..ae652f60e 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift @@ -241,7 +241,7 @@ extension AccessibilityDeferral { var updated = content let matches = sources.filter { $0.contentIdentifier == content.sourceIdentifier } // If multiple sources share an identifier we cannot pick safely; first wins - // and the rest stay visible to assistive tech rather than crashing. + // and the rest stay visible to assistive tech. let match = matches.first match?.accessibilityElementsHidden = true updated.inheritedAccessibility = match?.accessibility @@ -484,9 +484,9 @@ extension AccessibilityDeferral.Receiver { guard let content, !content.isEmpty else { replaceContent([]); return } guard let updateID = content.first?.updateIdentifier, content.allSatisfy({ $0.updateIdentifier == updateID }) else { // Entries in one batch should share an updateIdentifier within a layout pass. - // If they don't we cannot safely merge stale content — treat as a fresh replace. - replaceContent(content) - updateDeferredAccessibility(frameProvider: frameProvider) + // A mismatched batch means upstream is malformed — we can't trust the batch is + // fresher than what's already applied, so leave existing content untouched. + // The next legitimate broker pass will overwrite it. return } let lastUpdateID = deferredAccessibilityContent?.first?.updateIdentifier diff --git a/BlueprintUIAccessibilityCore/Tests/AccessibilityDeferralTests.swift b/BlueprintUIAccessibilityCore/Tests/AccessibilityDeferralTests.swift index e798f6dc4..7a7935cff 100644 --- a/BlueprintUIAccessibilityCore/Tests/AccessibilityDeferralTests.swift +++ b/BlueprintUIAccessibilityCore/Tests/AccessibilityDeferralTests.swift @@ -134,33 +134,32 @@ class AccessibilityDeferralTests: XCTestCase { // MARK: - Recovery from malformed input - func test_apply_recovers_from_mismatched_updateIdentifiers() { + func test_apply_ignores_malformed_batch_with_mismatched_updateIdentifiers() { let receiver = TestReceiver() // Seed the receiver with valid content sharing one updateID. - let firstUpdateID = UUID() var seed = AccessibilityDeferral.Content(kind: .inherited(), identifier: "source1") - seed.updateIdentifier = firstUpdateID + seed.updateIdentifier = UUID() seed.inheritedAccessibility = makeRepresentation(label: "Seed") receiver.apply(content: [seed], frameProvider: nil) XCTAssertEqual(receiver.deferredAccessibilityContent?.count, 1) + XCTAssertEqual(receiver.deferredAccessibilityContent?.first?.inheritedAccessibility?.label, "Seed") // Now hand it a malformed batch with two different updateIDs. var malformedA = AccessibilityDeferral.Content(kind: .inherited(), identifier: "sourceA") malformedA.updateIdentifier = UUID() - malformedA.inheritedAccessibility = makeRepresentation(label: "Recovered A") + malformedA.inheritedAccessibility = makeRepresentation(label: "Malformed A") var malformedB = AccessibilityDeferral.Content(kind: .inherited(), identifier: "sourceB") malformedB.updateIdentifier = UUID() - malformedB.inheritedAccessibility = makeRepresentation(label: "Recovered B") + malformedB.inheritedAccessibility = makeRepresentation(label: "Malformed B") - // Previously this crashed via fatalError. Now it should recover by replacing rather than merging. + // A malformed batch (entries with differing updateIdentifiers) cannot be trusted as + // fresher than what's already applied, so existing content stays in place. receiver.apply(content: [malformedA, malformedB], frameProvider: nil) - // Content should be the malformed batch (replaced), not the seed. - XCTAssertEqual(receiver.deferredAccessibilityContent?.count, 2) - XCTAssertEqual(receiver.deferredAccessibilityContent?.first?.inheritedAccessibility?.label, "Recovered A") - XCTAssertEqual(receiver.deferredAccessibilityContent?.last?.inheritedAccessibility?.label, "Recovered B") + XCTAssertEqual(receiver.deferredAccessibilityContent?.count, 1) + XCTAssertEqual(receiver.deferredAccessibilityContent?.first?.inheritedAccessibility?.label, "Seed") } // MARK: - Content customContent generation From 6e8a521ef9dceac880f1ba9c922dadf31fe5473c Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Wed, 27 May 2026 15:43:45 +0200 Subject: [PATCH 3/5] fix(a11y): assertionFailure on deferral programming bugs 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) --- .../Sources/AccessibilityDeferral.swift | 16 ++++++---- .../Tests/AccessibilityDeferralTests.swift | 30 ------------------- 2 files changed, 10 insertions(+), 36 deletions(-) diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift index ae652f60e..acca10bb4 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift @@ -229,6 +229,7 @@ extension AccessibilityDeferral { guard receivers.count <= 1 else { // A ParentContainer must contain at most one Receiver. If a consumer wires up // more than one we cannot pick safely, so fail safe by clearing every receiver. + assertionFailure("AccessibilityDeferral.ParentContainer must contain at most one Receiver; found \(receivers.count).") receivers.forEach { $0.apply(content: nil, frameProvider: nil) } return } @@ -240,8 +241,11 @@ extension AccessibilityDeferral { let deferredContent = contents?.map { content in var updated = content let matches = sources.filter { $0.contentIdentifier == content.sourceIdentifier } - // If multiple sources share an identifier we cannot pick safely; first wins - // and the rest stay visible to assistive tech. + if matches.count > 1 { + // Source identifiers must be unique within a ParentContainer. + // First match wins; the rest stay visible to assistive tech. + assertionFailure("Found multiple deferral sources with the same identifier \(content.sourceIdentifier).") + } let match = matches.first match?.accessibilityElementsHidden = true updated.inheritedAccessibility = match?.accessibility @@ -483,10 +487,10 @@ extension AccessibilityDeferral.Receiver { ) { guard let content, !content.isEmpty else { replaceContent([]); return } guard let updateID = content.first?.updateIdentifier, content.allSatisfy({ $0.updateIdentifier == updateID }) else { - // Entries in one batch should share an updateIdentifier within a layout pass. - // A mismatched batch means upstream is malformed — we can't trust the batch is - // fresher than what's already applied, so leave existing content untouched. - // The next legitimate broker pass will overwrite it. + // Entries in one batch must share an updateIdentifier within a layout pass. + // A mismatched batch is a programming bug; the malformed batch cannot be trusted + // as fresher than what's already applied, so leave existing content untouched. + assertionFailure("Deferral content batch has mismatched updateIdentifiers; ignoring.") return } let lastUpdateID = deferredAccessibilityContent?.first?.updateIdentifier diff --git a/BlueprintUIAccessibilityCore/Tests/AccessibilityDeferralTests.swift b/BlueprintUIAccessibilityCore/Tests/AccessibilityDeferralTests.swift index 7a7935cff..c7d1c455c 100644 --- a/BlueprintUIAccessibilityCore/Tests/AccessibilityDeferralTests.swift +++ b/BlueprintUIAccessibilityCore/Tests/AccessibilityDeferralTests.swift @@ -132,36 +132,6 @@ class AccessibilityDeferralTests: XCTestCase { XCTAssertEqual(receiver.accessibilityCustomActions?.first?.name, "Test Action") } - // MARK: - Recovery from malformed input - - func test_apply_ignores_malformed_batch_with_mismatched_updateIdentifiers() { - let receiver = TestReceiver() - - // Seed the receiver with valid content sharing one updateID. - var seed = AccessibilityDeferral.Content(kind: .inherited(), identifier: "source1") - seed.updateIdentifier = UUID() - seed.inheritedAccessibility = makeRepresentation(label: "Seed") - receiver.apply(content: [seed], frameProvider: nil) - XCTAssertEqual(receiver.deferredAccessibilityContent?.count, 1) - XCTAssertEqual(receiver.deferredAccessibilityContent?.first?.inheritedAccessibility?.label, "Seed") - - // Now hand it a malformed batch with two different updateIDs. - var malformedA = AccessibilityDeferral.Content(kind: .inherited(), identifier: "sourceA") - malformedA.updateIdentifier = UUID() - malformedA.inheritedAccessibility = makeRepresentation(label: "Malformed A") - - var malformedB = AccessibilityDeferral.Content(kind: .inherited(), identifier: "sourceB") - malformedB.updateIdentifier = UUID() - malformedB.inheritedAccessibility = makeRepresentation(label: "Malformed B") - - // A malformed batch (entries with differing updateIdentifiers) cannot be trusted as - // fresher than what's already applied, so existing content stays in place. - receiver.apply(content: [malformedA, malformedB], frameProvider: nil) - - XCTAssertEqual(receiver.deferredAccessibilityContent?.count, 1) - XCTAssertEqual(receiver.deferredAccessibilityContent?.first?.inheritedAccessibility?.label, "Seed") - } - // MARK: - Content customContent generation func test_content_inherited_customContent() { From 53fd6cc518f9d5f30c9ff2a85d6bdaa89862fd47 Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Wed, 27 May 2026 15:50:39 +0200 Subject: [PATCH 4/5] chore(a11y): drop redundant comments from deferral assert sites The assertionFailure messages already describe the constraint. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Sources/AccessibilityDeferral.swift | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift index acca10bb4..3b6a3807b 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift @@ -227,8 +227,6 @@ extension AccessibilityDeferral { guard receivers.count <= 1 else { - // A ParentContainer must contain at most one Receiver. If a consumer wires up - // more than one we cannot pick safely, so fail safe by clearing every receiver. assertionFailure("AccessibilityDeferral.ParentContainer must contain at most one Receiver; found \(receivers.count).") receivers.forEach { $0.apply(content: nil, frameProvider: nil) } return @@ -242,9 +240,7 @@ extension AccessibilityDeferral { var updated = content let matches = sources.filter { $0.contentIdentifier == content.sourceIdentifier } if matches.count > 1 { - // Source identifiers must be unique within a ParentContainer. - // First match wins; the rest stay visible to assistive tech. - assertionFailure("Found multiple deferral sources with the same identifier \(content.sourceIdentifier).") + assertionFailure("Found multiple deferral sources with the same identifier \(content.sourceIdentifier); using first match.") } let match = matches.first match?.accessibilityElementsHidden = true @@ -487,9 +483,6 @@ extension AccessibilityDeferral.Receiver { ) { guard let content, !content.isEmpty else { replaceContent([]); return } guard let updateID = content.first?.updateIdentifier, content.allSatisfy({ $0.updateIdentifier == updateID }) else { - // Entries in one batch must share an updateIdentifier within a layout pass. - // A mismatched batch is a programming bug; the malformed batch cannot be trusted - // as fresher than what's already applied, so leave existing content untouched. assertionFailure("Deferral content batch has mismatched updateIdentifiers; ignoring.") return } From b38d7eda841886280b2bce83f85f2757633b27a4 Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Wed, 27 May 2026 15:52:10 +0200 Subject: [PATCH 5/5] fix(a11y): treat duplicate deferral sources as no match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Sources/AccessibilityDeferral.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift index 3b6a3807b..faa52870c 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift @@ -240,9 +240,9 @@ extension AccessibilityDeferral { var updated = content let matches = sources.filter { $0.contentIdentifier == content.sourceIdentifier } if matches.count > 1 { - assertionFailure("Found multiple deferral sources with the same identifier \(content.sourceIdentifier); using first match.") + assertionFailure("Found multiple deferral sources with the same identifier \(content.sourceIdentifier); ignoring.") } - let match = matches.first + let match = matches.count == 1 ? matches.first : nil match?.accessibilityElementsHidden = true updated.inheritedAccessibility = match?.accessibility updated.updateIdentifier = updateID