Skip to content

Complete the transition instead of trapping in interruptibleAnimator - #686

Open
hsoi wants to merge 2 commits into
scenee:masterfrom
siperapps:upstream/complete-transition-instead-of-trapping
Open

Complete the transition instead of trapping in interruptibleAnimator#686
hsoi wants to merge 2 commits into
scenee:masterfrom
siperapps:upstream/complete-transition-instead-of-trapping

Conversation

@hsoi

@hsoi hsoi commented Jul 30, 2026

Copy link
Copy Markdown

Thanks for #642 — this is a follow-up to it.

#641 was titled for interruptibleAnimator, but the code quoted in the report was transitionDuration, so #642 fixed the methods that were quoted. Both interruptibleAnimator methods still have the bare fatalError(), and they trap on the same condition:

guard let fpc = transitionContext.viewController(forKey: .from) as? FloatingPanelController
else { fatalError() }   // Transitioning.swift:131

When it fires

A panel is presented modally on a view controller that is itself presented modally. Dismissing the outermost presentation tears the whole stack down in one step, and UIKit then runs the panel's dismiss transition with the intermediate view controller as .from. The cast fails and the app is killed.

Impact

This is currently the single highest-volume crash in a shipping iOS app. It's a latent condition that started firing at scale without any app-side change — the same unmodified binary that had been stable for weeks began crashing, which points at an OS behaviour change rather than anything in the app. It's overwhelmingly iOS 26.5.x, but a small share of occurrences are on iOS 18.x, so it isn't iOS 26-specific.

_assertionFailure
specialized ModalDismissTransition.interruptibleAnimator(using:) (Transitioning.swift:131)
@objc ModalPresentTransition.animateTransition(using:)          <- linker-folded thunk; the dismiss path
___UIViewControllerTransitioningRunCustomTransitionWithRequest_block_invoke_3
+[UIKeyboardSceneDelegate _pinInputViewsForKeyboardSceneDelegate:onBehalfOfResponder:duringBlock:]
___UIViewControllerTransitioningRunCustomTransitionWithRequest_block_invoke_2
+[UIView(Animation) _setAlongsideAnimations:toRunByEndOfBlock:animated:]
_UIViewControllerTransitioningRunCustomTransitionWithRequest
__77-[UIPresentationController runTransitionForCurrentStateAnimated:handoffData:]_block_invoke_3

The keyboard-pinning frame appears in every sample, so a keyboard transition is consistently in flight at the same time.

The change

Both guards now return an animator that immediately completes the transition. The two nil-transitionAnimator fallbacks below them deliberately return a non-completing animator instead, because show()/hide() directly above have already scheduled completeTransition and completing twice would be worse than the trap. This also removes a force-unwrap of fpc.transitionAnimator on the dismiss path.

Worst case after this is a panel that disappears without animating, rather than a terminated process.

Tests

Added to ControllerTests, covering both guard paths, plus one pinning the existing zero-duration transitionDuration behaviour on the same condition. MockTransitionContext follows the existing MockTransitionCoordinator in TestSupports. The dismiss test also verifies completeTransition is genuinely called, since a fallback that silently never completes would be worse than the crash.

Left alone deliberately: the fatalError() in PresentationController.addFloatingPanel(), where presentedViewController is the panel by construction — happy to fold it in if you'd prefer.

hsoi and others added 2 commits July 30, 2026 13:42
scenee#642 replaced the fatalError() in both transitionDuration methods with a
graceful return, after scenee#641 reported crashes there. The sibling
interruptibleAnimator methods kept their bare fatalError(), and they trap on
the same condition: UIKit running the transition with a context whose
participating view controller isn't the FloatingPanelController.

This is currently the top crasher in a shipping app (~110 events/week across
~95 users). It is overwhelmingly iOS 26, but reproduces on iOS 18 as well. It
fires when a stack of presented controllers is torn down in one step: the panel
is presented on a view controller that is itself being dismissed, so the dismiss
transition runs with the outer controller as .from.

Both guards now return an animator that immediately completes the transition.
The nil-transitionAnimator fallbacks deliberately return a *non*-completing
animator instead, because show()/hide() directly above have already scheduled
completeTransition and completing it twice would be worse than the trap.

Also removes a force-unwrap of fpc.transitionAnimator on the dismiss path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers the guard paths in both interruptibleAnimator methods: the transition
completes rather than trapping when the context's participating view controller
isn't a FloatingPanelController. Also pins the existing zero-duration behaviour
of transitionDuration on the same condition.

MockTransitionContext follows MockTransitionCoordinator in TestSupports.

Co-Authored-By: Claude Opus 5 (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.

1 participant