diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs index 7decf1f7dd..310e8a4ff6 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs @@ -296,6 +296,45 @@ void HandlePopupPageClosed(object? sender, IPopupResult e) } } + [Fact] + public async Task ShowPopupAsync_FromModalNavigationPage_ShouldCloseSuccessfully() + { + // Remove shell navigation + var rootPage = new ContentPage { Title = "Root" }; + + if (Application.Current is null) + { + throw new InvalidOperationException("Application.Current is null. Unable to set the root page."); + } + + if (Application.Current.Windows.Count == 0) + { + throw new InvalidOperationException("No application windows found. Unable to set the root page."); + } + + Application.Current.Windows[0].Page = rootPage; + var modalNavigationPage = new NavigationPage(new ContentPage { Title = "Modal Navigation Page" }); + var popup = new Popup(); + + // Act - Push modal navigation page + await rootPage.Navigation.PushModalAsync(modalNavigationPage, false); + + // Assert - Verify modal stack has the modal navigation page + Assert.Single(rootPage.Navigation.ModalStack); + Assert.Same(modalNavigationPage, rootPage.Navigation.ModalStack[0]); + + // Act + var showPopupAsyncTask = modalNavigationPage.ShowPopupAsync(popup, token: TestContext.Current.CancellationToken); + await popup.CloseAsync("Hello", TestContext.Current.CancellationToken); + var result = await showPopupAsyncTask; + + // Assert + result.Result.Should().Be("Hello"); + + // Cleanup + await rootPage.Navigation.PopModalAsync(false); + } + [Fact] public void PopupPageT_Close_ShouldThrowOperationCanceledException_WhenTokenIsCancelled() { @@ -615,6 +654,87 @@ public async Task Close_ShouldThrowException_WhenCalledOnNonModalPopup() await Assert.ThrowsAsync(async () => await popupPage.CloseAsync(new PopupResult(false), TestContext.Current.CancellationToken)); } + [Fact] + public async Task CloseAsync_ShouldThrowPopupBlockedException_WhenPopupIsBehindModalNavigationPage() + { + // Arrange + if (Application.Current?.Windows[0].Page?.Navigation is not INavigation navigation) + { + throw new InvalidOperationException("Unable to locate Navigation page"); + } + + bool wasPopupPageClosed = false; + + var popupPage = new PopupPage(new ContentView(), new MockPopupOptions()); + popupPage.PopupClosed += HandlePopupPageClosed; + + var modalNavigationPage = new NavigationPage(new ContentPage()); + + // Act + + // Push popup, then push a modal NavigationPage on top + // Modal stack: [PopupPage, NavigationPage(ContentPage)] + await navigation.PushModalAsync(popupPage); + await navigation.PushModalAsync(modalNavigationPage); + + // Assert + + // When the top of the modal stack is an IPageContainer whose CurrentPage is NOT a PopupPage, + // CloseAsync should throw PopupBlockedException because PopModalAsync would pop the + // visible NavigationPage instead of the PopupPage, leaving the PopupPage stranded on the + // modal stack while still incorrectly firing PopupClosed/NotifyPopupIsClosed. + await Assert.ThrowsAsync(async () => await popupPage.CloseAsync(new PopupResult(false), TestContext.Current.CancellationToken)); + await Assert.ThrowsAnyAsync(async () => await popupPage.CloseAsync(new PopupResult(false), TestContext.Current.CancellationToken)); + await Assert.ThrowsAnyAsync(async () => await popupPage.CloseAsync(new PopupResult(false), TestContext.Current.CancellationToken)); + + // Verify popup was NOT closed — it should still be on the modal stack + Assert.False(wasPopupPageClosed); + Assert.Contains(popupPage, navigation.ModalStack); + + void HandlePopupPageClosed(object? sender, IPopupResult e) + { + wasPopupPageClosed = true; + popupPage.PopupClosed -= HandlePopupPageClosed; + } + } + + [Fact] + public async Task CloseAsync_ShouldThrowPopupBlockedException_WhenPopupIsHiddenBehindAnotherPopupAndNavigationPage() + { + // Arrange + if (Application.Current?.Windows[0].Page?.Navigation is not INavigation navigation) + { + throw new InvalidOperationException("Unable to locate Navigation page"); + } + + bool wasPopupPageClosed = false; + + var firstPopupPage = new PopupPage(new ContentView(), new MockPopupOptions()); + firstPopupPage.PopupClosed += HandlePopupPageClosed; + + var secondPopupPage = new PopupPage(new Button(), new MockPopupOptions()); + var navigationPageOnTop = new NavigationPage(new ContentPage()); + + // Act + + // Push first popup, second popup, then NavigationPage on top + await navigation.PushModalAsync(firstPopupPage); + await navigation.PushModalAsync(secondPopupPage); + await navigation.PushModalAsync(navigationPageOnTop); + + // Assert + await Assert.ThrowsAsync(async () => await firstPopupPage.CloseAsync(new PopupResult(false), TestContext.Current.CancellationToken)); + await Assert.ThrowsAnyAsync(async () => await firstPopupPage.CloseAsync(new PopupResult(false), TestContext.Current.CancellationToken)); + await Assert.ThrowsAnyAsync(async () => await firstPopupPage.CloseAsync(new PopupResult(false), TestContext.Current.CancellationToken)); + Assert.False(wasPopupPageClosed); + + void HandlePopupPageClosed(object? sender, IPopupResult e) + { + wasPopupPageClosed = true; + firstPopupPage.PopupClosed -= HandlePopupPageClosed; + } + } + [Fact] public void PopupPage_ShouldRespectLayoutOptions() { diff --git a/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs b/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs index 32f6f9714e..a1ae6fc429 100644 --- a/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs +++ b/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs @@ -77,27 +77,37 @@ public async Task CloseAsync(PopupResult result, CancellationToken token = defau token.ThrowIfCancellationRequested(); // Handle edge case where a Popup was pushed inside a custom IPageContainer (e.g. a NavigationPage) on the Modal Stack - var customPageContainer = Navigation.ModalStack.OfType>().LastOrDefault(); - if (customPageContainer is not null && customPageContainer.CurrentPage is not PopupPage) + var navigationPageOnModalStackContainingPopupPage = Navigation.ModalStack.OfType>().LastOrDefault(); + if (navigationPageOnModalStackContainingPopupPage is not null && navigationPageOnModalStackContainingPopupPage.CurrentPage is not PopupPage) { - throw new PopupNotFoundException(); + navigationPageOnModalStackContainingPopupPage = null; } - var popupPageToClose = customPageContainer?.CurrentPage as PopupPage + var popupPageToClose = navigationPageOnModalStackContainingPopupPage?.CurrentPage as PopupPage ?? Navigation.ModalStack.OfType().LastOrDefault() ?? throw new PopupNotFoundException(); // PopModalAsync will pop the last (top) page from the ModalStack // Ensure that the PopupPage the user is attempting to close is the last (top) page on the Modal stack before calling Navigation.PopModalAsync - if (Navigation.ModalStack[^1] is IPageContainer { CurrentPage: PopupPage visiblePopupPageInCustomPageContainer } - && visiblePopupPageInCustomPageContainer.Content != Content) + switch (Navigation.ModalStack[^1]) { - throw new PopupBlockedException(visiblePopupPageInCustomPageContainer); + // Handle the edge case where the visible modal page is a navigation page containing a Popup that is not the Popup to be closed + case IPageContainer { CurrentPage: PopupPage visiblePopupPageInCustomPageContainer } when visiblePopupPageInCustomPageContainer.Content != Content: + throw new PopupBlockedException(visiblePopupPageInCustomPageContainer); + + // Handle edge case where the top of the modal stack is an IPageContainer whose CurrentPage is NOT a PopupPage + // (e.g. a modal NavigationPage pushed after showing a popup). + case IPageContainer { CurrentPage: not PopupPage }: + throw new PopupBlockedException(Navigation.ModalStack[^1]); + + // Handle edge case where the visible modal page is not the Popup to be closed + case ContentPage currentVisibleModalPage when currentVisibleModalPage.Content != Content: + throw new PopupBlockedException(currentVisibleModalPage); } - else if (Navigation.ModalStack[^1] is ContentPage currentVisibleModalPage - && currentVisibleModalPage.Content != Content) + + if (popupPageToClose.Content != Content) { - throw new PopupBlockedException(currentVisibleModalPage); + throw new PopupBlockedException(popupPageToClose); } // We call `.ThrowIfCancellationRequested()` again to avoid a race condition where a developer cancels the CancellationToken after we check for an InvalidOperationException