diff --git a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs index e847e73e8952..fe5f6ae29cc1 100644 --- a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs +++ b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs @@ -107,7 +107,7 @@ Task PopModalPlatformAsync(bool animated) return Task.FromResult(modal); } - var source = new TaskCompletionSource(); + var source = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (animated && dialogFragment.View is not null) { @@ -168,8 +168,6 @@ async Task PushModalPlatformAsync(Page modal, bool animated) async Task PresentModal(Page modal, bool animated) { - TaskCompletionSource animationCompletionSource = new(); - var parentView = GetModalParentView(); var dialogFragment = new ModalFragment(WindowMauiContext, modal) @@ -185,19 +183,32 @@ async Task PresentModal(Page modal, bool animated) if (animated) { - dialogFragment!.AnimationEnded += OnAnimationEnded; + TaskCompletionSource animationCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + + dialogFragment.AnimationEnded += OnAnimationEnded; + + void OnAnimationEnded(object? sender, EventArgs e) + { + dialogFragment.AnimationEnded -= OnAnimationEnded; + animationCompletionSource.SetResult(true); + } await animationCompletionSource.Task; } else { - animationCompletionSource.TrySetResult(true); - } + // Non-animated modals need to wait for presentation completion to prevent race conditions + TaskCompletionSource presentationCompletionSource = new(); - void OnAnimationEnded(object? sender, EventArgs e) - { - dialogFragment!.AnimationEnded -= OnAnimationEnded; - animationCompletionSource.SetResult(true); + dialogFragment.PresentationCompleted += OnPresentationCompleted; + + void OnPresentationCompleted(object? sender, EventArgs e) + { + dialogFragment.PresentationCompleted -= OnPresentationCompleted; + presentationCompletionSource.SetResult(true); + } + + await presentationCompletionSource.Task; } } @@ -208,9 +219,10 @@ internal class ModalFragment : DialogFragment NavigationRootManager? _navigationRootManager; static readonly ColorDrawable TransparentColorDrawable = new(AColor.Transparent); bool _pendingAnimation = true; + bool _pendingNavigation = true; - public event EventHandler? AnimationEnded; - + internal event EventHandler? AnimationEnded; + internal event EventHandler? PresentationCompleted; public bool IsAnimated { get; internal set; } @@ -356,13 +368,25 @@ public override void OnStart() var dialog = Dialog; if (dialog is null || dialog.Window is null || View is null) + { + // SAFETY: Fire event even on early return to prevent deadlock + FirePresentationCompleted(); return; + } int width = ViewGroup.LayoutParams.MatchParent; int height = ViewGroup.LayoutParams.MatchParent; dialog.Window.SetLayout(width, height); } + public override void OnResume() + { + base.OnResume(); + + // Signal that the modal is fully presented and ready + FirePresentationCompleted(); + } + public override void OnDismiss(IDialogInterface dialog) { _modal.PropertyChanged -= OnModalPagePropertyChanged; @@ -385,6 +409,9 @@ public override void OnDestroy() { base.OnDestroy(); FireAnimationEnded(); + + // SAFETY: If destroyed before OnStart completed, fire PresentationCompleted to prevent deadlock + FirePresentationCompleted(); } void FireAnimationEnded() @@ -398,6 +425,15 @@ void FireAnimationEnded() AnimationEnded?.Invoke(this, EventArgs.Empty); } + void FirePresentationCompleted() + { + if (!_pendingNavigation) + return; + + _pendingNavigation = false; + PresentationCompleted?.Invoke(this, EventArgs.Empty); + } + sealed class CustomComponentDialog : ComponentDialog { diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ModalNavigationShouldNotHang.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ModalNavigationShouldNotHang.png new file mode 100644 index 000000000000..d14e66f1b88b Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ModalNavigationShouldNotHang.png differ diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue32310.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue32310.cs new file mode 100644 index 000000000000..01633aa28d6a --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue32310.cs @@ -0,0 +1,42 @@ +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 32310, "App hangs if PopModalAsync is called after PushModalAsync with single await Task.Yield()", PlatformAffected.Android)] +public class Issue32310 : ContentPage +{ + public Issue32310() + { + var navigateButton = new Button + { + Text = "Perform Modal Navigation", + AutomationId = "NavigateButton", + VerticalOptions = LayoutOptions.Center, + HorizontalOptions = LayoutOptions.Center + }; + + navigateButton.Clicked += (s, e) => + { + Dispatcher.DispatchAsync(async () => + { + await Navigation.PushModalAsync(new ContentPage() { Content = new Label() { Text = "Hello!" } }, false); + + await Task.Yield(); + + await Navigation.PopModalAsync(); + }); + }; + + var layout = new VerticalStackLayout + { + Padding = new Thickness(30, 0), + Spacing = 25, + VerticalOptions = LayoutOptions.Center, + HorizontalOptions = LayoutOptions.Center, + Children = + { + navigateButton + } + }; + + Content = layout; + } +} diff --git a/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/ModalNavigationShouldNotHang.png b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/ModalNavigationShouldNotHang.png new file mode 100644 index 000000000000..cbdb4b9d01ff Binary files /dev/null and b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/ModalNavigationShouldNotHang.png differ diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32310.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32310.cs new file mode 100644 index 000000000000..5493c6a78cd7 --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32310.cs @@ -0,0 +1,24 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue32310 : _IssuesUITest +{ + public Issue32310(TestDevice device) : base(device) + { + } + + public override string Issue => "App hangs if PopModalAsync is called after PushModalAsync with single await Task.Yield()"; + + [Test] + [Category(UITestCategories.Navigation)] + public void ModalNavigationShouldNotHang() + { + App.WaitForElement("NavigateButton"); + App.Tap("NavigateButton"); + App.WaitForElement("NavigateButton"); + VerifyScreenshot(); + } +} diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/ModalNavigationShouldNotHang.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/ModalNavigationShouldNotHang.png new file mode 100644 index 000000000000..b3466e42acb0 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/ModalNavigationShouldNotHang.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/ModalNavigationShouldNotHang.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/ModalNavigationShouldNotHang.png new file mode 100644 index 000000000000..6db4eb820acb Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/ModalNavigationShouldNotHang.png differ