diff --git a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs index e847e73e8952..69e7fafe40a3 100644 --- a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs +++ b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs @@ -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,33 @@ async Task PresentModal(Page modal, bool animated) if (animated) { - dialogFragment!.AnimationEnded += OnAnimationEnded; + TaskCompletionSource animationCompletionSource = new(); + + 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 + // when PopModalAsync is called immediately after PushModalAsync (e.g., with Task.Yield()) + 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,8 +220,10 @@ internal class ModalFragment : DialogFragment NavigationRootManager? _navigationRootManager; static readonly ColorDrawable TransparentColorDrawable = new(AColor.Transparent); bool _pendingAnimation = true; + bool _presentationCompleted = false; - public event EventHandler? AnimationEnded; + internal event EventHandler? AnimationEnded; + internal event EventHandler? PresentationCompleted; public bool IsAnimated { get; internal set; } @@ -356,11 +370,18 @@ 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); + + // Signal that the modal is fully presented and ready + FirePresentationCompleted(); } public override void OnDismiss(IDialogInterface dialog) @@ -385,6 +406,9 @@ public override void OnDestroy() { base.OnDestroy(); FireAnimationEnded(); + + // SAFETY: If destroyed before OnStart completed, fire PresentationCompleted to prevent deadlock + FirePresentationCompleted(); } void FireAnimationEnded() @@ -398,6 +422,17 @@ void FireAnimationEnded() AnimationEnded?.Invoke(this, EventArgs.Empty); } + void FirePresentationCompleted() + { + if (_presentationCompleted) + { + return; + } + + _presentationCompleted = true; + PresentationCompleted?.Invoke(this, EventArgs.Empty); + } + sealed class CustomComponentDialog : ComponentDialog { 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.Shared.Tests/Tests/Issues/Issue32310.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32310.cs new file mode 100644 index 000000000000..78f5f350d97e --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32310.cs @@ -0,0 +1,26 @@ +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"); + + // If the fix works, the modal should push and pop quickly, + // and we should be back to the same page with the button visible + App.WaitForElement("NavigateButton", timeout: TimeSpan.FromSeconds(10)); + } +}