Skip to content

fix(ios): guard against double touchesEnded delivery on iOS 26 - #669

Open
hggzm wants to merge 1 commit into
mainfrom
users/hggzm/clean/fix-ios26-toggle-double-fire
Open

hggzm wants to merge 1 commit into
mainfrom
users/hggzm/clean/fix-ios26-toggle-double-fire

Conversation

@hggzm

@hggzm hggzm commented Mar 27, 2026

Copy link
Copy Markdown
Collaborator

Problem

Action.ToggleVisibility fires twice on iOS 26, causing toggled content to open then immediately collapse.

iOS 26 introduced a new Gestures framework that re-delivers touchesEnded:withEvent: twice for the same physical tap:

  1. Once through the normal UIKit dispatch path (AXPApplication.sendEvent:)
  2. A second time via a deferred CoreFoundation run-loop callback

Critically, the second delivery uses a new UIEvent object (different pointer), so simple event pointer deduplication doesn't work.

In ACRContentStackView, touchesEnded:withEvent: calls doSelectAction unconditionally, so Action.ToggleVisibility fires twice per tap — opening the card then immediately closing it again.

Customer Impact

This blocks Fast Retailing's ServiceNow Now Assist rollout (100+ stores worldwide). Their card uses ColumnSet.selectAction with Action.ToggleVisibility to expand/collapse a "Sources" section. Microsoft support case: TrackingID# 2603110030008961.

Version Matrix (Customer-Reported)

iOS Version Teams Version Issue Occurs
26.3.1 8.3.1 YES
26.1 8.3.1 YES
17.7 8.3.1 NO
26.3.1 8.2.1 NO

Same card works on Android, Desktop, Web.

Fix

In ACRContentStackView.mm:

  1. Added BOOL _hasFiredActionForCurrentTouch ivar
  2. touchesBegan:withEvent: resets the flag to NO at the start of each new touch sequence
  3. touchesEnded:withEvent: checks the flag before firing — skips on re-delivery, sets it to YES on first fire
  4. Added touchesCancelled:withEvent: handler to reset the flag and forward the event
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    _hasFiredActionForCurrentTouch = NO;
    // ... existing logic
}

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    if (self.selectActionTarget) {
        if (_hasFiredActionForCurrentTouch) { return; }
        _hasFiredActionForCurrentTouch = YES;
        [self.selectActionTarget doSelectAction];
    } else {
        [self.nextResponder touchesEnded:touches withEvent:event];
    }
}

Why This Fix Is Safe

  • The guard is entirely local to the touch sequence lifecycle (touchesBegan → touchesEnded/touchesCancelled)
  • It does not affect any other action types — only prevents duplicate delivery within the same touch
  • touchesCancelled properly resets the flag to avoid stuck state
  • No Teams app changes required — fix is confined to the AdaptiveCards SDK

How Verified

  • iOS 26.2 simulator (Xcode 16.4, macos-15 GitHub Actions runner, com.apple.CoreSimulator.SimRuntime.iOS-26-2)
  • Custom testToggleVisibilityDoubleFire UI test:
    • Navigates to Action.ToggleVisibility.json card
    • Taps toggle button, waits 2s
    • Result: Element count changed from 54 → 56 (content stayed expanded, no double-fire)
    • Taps again, waits 2s
    • Result: Element count returned to 54 (clean round-trip)
  • All existing unit tests pass (SDK Build Gate: ✅, Agent Validation Gate: ✅)
  • iOS Visual Regression: ✅ (no screenshot diffs)
  • AXe A11y Pipeline: ✅

hggzm added a commit to hggzm/Teams-AdaptiveCards-Mobile that referenced this pull request Mar 27, 2026
iOS 26 introduced a new Gestures framework that re-delivers
touchesEnded:withEvent: twice for the same physical tap  once through
the normal UIKit dispatch path, and a second time via a deferred
CoreFoundation run-loop callback with a new UIEvent object pointer.

In ACRContentStackView, touchesEnded calls doSelectAction
unconditionally, so Action.ToggleVisibility fired twice per tap
opening the card then immediately closing it again.

Fix:
- Added BOOL _hasFiredActionForCurrentTouch ivar
- touchesBegan resets the flag at the start of each new touch sequence
- touchesEnded checks the flag before firing  skips re-delivery
- Added touchesCancelled handler to reset flag and forward event

Includes 6 headless unit tests (ACRContentStackViewDoubleTouchTests.mm):
- testSingleTouchFiresOnce
- testDoubleTouchesEndedFiresOnlyOnce (core iOS 26 scenario)
- testTouchesCancelledResetsGuard
- testTwoSeparateTouchSequencesBothFire
- testTripleTouchesEndedFiresOnlyOnce
- testNoSelectActionTargetNoFire

Validated on iOS 26.2 simulator (Xcode 16.4, macos-15 runner).
@hggzm
hggzm force-pushed the users/hggzm/clean/fix-ios26-toggle-double-fire branch from ab39648 to bf7064b Compare March 27, 2026 14:44
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