Skip to content
120 changes: 120 additions & 0 deletions src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

// 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<string>(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()
{
Expand Down Expand Up @@ -615,6 +654,87 @@ public async Task Close_ShouldThrowException_WhenCalledOnNonModalPopup()
await Assert.ThrowsAsync<PopupNotFoundException>(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<string>(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<PopupBlockedException>(async () => await popupPage.CloseAsync(new PopupResult(false), TestContext.Current.CancellationToken));
await Assert.ThrowsAnyAsync<InvalidPopupOperationException>(async () => await popupPage.CloseAsync(new PopupResult(false), TestContext.Current.CancellationToken));
await Assert.ThrowsAnyAsync<InvalidOperationException>(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<string>(new ContentView(), new MockPopupOptions());
firstPopupPage.PopupClosed += HandlePopupPageClosed;

var secondPopupPage = new PopupPage<string>(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<PopupBlockedException>(async () => await firstPopupPage.CloseAsync(new PopupResult(false), TestContext.Current.CancellationToken));
await Assert.ThrowsAnyAsync<InvalidPopupOperationException>(async () => await firstPopupPage.CloseAsync(new PopupResult(false), TestContext.Current.CancellationToken));
await Assert.ThrowsAnyAsync<InvalidOperationException>(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()
{
Expand Down
30 changes: 20 additions & 10 deletions src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IPageContainer<Page>>().LastOrDefault();
if (customPageContainer is not null && customPageContainer.CurrentPage is not PopupPage)
var navigationPageOnModalStackContainingPopupPage = Navigation.ModalStack.OfType<IPageContainer<Page>>().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<PopupPage>().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<Page> { 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<Page> { 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<Page> { 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);
Comment thread
TheCodeTraveler marked this conversation as resolved.
}
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
Expand Down
Loading