Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions packages/react-router/src/ReactRouter/StackManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -657,9 +657,25 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
this.ionPageWaitTimeout = undefined;
}
this.pendingPageTransition = false;

const foundView = this.context.findViewItemByRouteInfo(routeInfo, this.id);
if (foundView) {
const oldPageElement = foundView.ionPageElement;

/**
* FIX for issue #28878: Reject orphaned IonPage registrations.
*
* When a component conditionally renders different IonPages (e.g., list vs empty state)
* using React keys, and state changes simultaneously with navigation, the new IonPage
* tries to register for a route we're navigating away from. This creates a stale view.
*
* Only reject if both pageIds exist and differ, to allow nested outlet registrations.
*/
if (this.shouldRejectOrphanedPage(page, oldPageElement, routeInfo)) {
this.hideAndRemoveOrphanedPage(page);
return;
}

foundView.ionPageElement = page;
foundView.ionRoute = true;

Expand All @@ -675,6 +691,45 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
this.handlePageTransition(routeInfo);
}

/**
* Determines if a new IonPage registration should be rejected as orphaned.
* This happens when a component re-renders with a different IonPage while navigating away.
*/
private shouldRejectOrphanedPage(
newPage: HTMLElement,
oldPageElement: HTMLElement | undefined,
routeInfo: RouteInfo
): boolean {
if (!oldPageElement || oldPageElement === newPage) {
return false;
}

const newPageId = newPage.getAttribute('data-pageid');
const oldPageId = oldPageElement.getAttribute('data-pageid');

// Only reject if both pageIds exist and are different
if (!newPageId || !oldPageId || newPageId === oldPageId) {
return false;
}

// Reject only if we're navigating away from this route
return this.props.routeInfo.pathname !== routeInfo.pathname;
}

/**
* Hides an orphaned IonPage and schedules its removal from the DOM.
*/
private hideAndRemoveOrphanedPage(page: HTMLElement): void {
page.classList.add('ion-page-hidden');
page.setAttribute('aria-hidden', 'true');

setTimeout(() => {
if (page.parentElement) {
page.remove();
}
}, VIEW_UNMOUNT_DELAY_MS);
}

/**
* Configures the router outlet for the swipe-to-go-back gesture.
*
Expand Down
2 changes: 2 additions & 0 deletions packages/react-router/test/base/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import TabHistoryIsolation from './pages/tab-history-isolation/TabHistoryIsolati
import Overlays from './pages/overlays/Overlays';
import NestedTabsRelativeLinks from './pages/nested-tabs-relative-links/NestedTabsRelativeLinks';
import RootSplatTabs from './pages/root-splat-tabs/RootSplatTabs';
import ContentChangeNavigation from './pages/content-change-navigation/ContentChangeNavigation';

setupIonicReact();

Expand Down Expand Up @@ -79,6 +80,7 @@ const App: React.FC = () => {
<Route path="relative-paths/*" element={<RelativePaths />} />
<Route path="/nested-tabs-relative-links/*" element={<NestedTabsRelativeLinks />} />
<Route path="/root-splat-tabs/*" element={<RootSplatTabs />} />
<Route path="/content-change-navigation/*" element={<ContentChangeNavigation />} />
</IonRouterOutlet>
</IonReactRouter>
</IonApp>
Expand Down
3 changes: 3 additions & 0 deletions packages/react-router/test/base/src/pages/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ const Main: React.FC = () => {
<IonItem routerLink="/root-splat-tabs">
<IonLabel>Root Splat Tabs</IonLabel>
</IonItem>
<IonItem routerLink="/content-change-navigation">
<IonLabel>Content Change Navigation</IonLabel>
</IonItem>
</IonList>
</IonContent>
</IonPage>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* Test case for GitHub issue #28878
* https://github.com/ionic-team/ionic-framework/issues/28878
*
* Reproduces the bug where changing view content while navigating causes
* an invalid view to be rendered.
*/

import {
IonContent,
IonHeader,
IonPage,
IonTitle,
IonToolbar,
IonList,
IonItem,
IonLabel,
IonButton,
IonRouterOutlet,
IonButtons,
IonBackButton,
} from '@ionic/react';
import React, { useState } from 'react';
import { Route, Navigate, useNavigate } from 'react-router-dom';

const ListPage: React.FC = () => {
const [items, setItems] = useState(['Item 1', 'Item 2', 'Item 3']);
const navigate = useNavigate();

const clearItemsAndNavigate = () => {
setItems([]);
navigate('/content-change-navigation/home');
};

// Using different keys forces React to unmount/remount IonPage
if (items.length === 0) {
return (
<IonPage key="empty" data-pageid="list-empty-page">
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/content-change-navigation/home" />
</IonButtons>
<IonTitle>Empty List</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div data-testid="empty-view">There are no items</div>
<IonButton routerLink="/content-change-navigation/home" data-testid="go-home-from-empty">
Go Home
</IonButton>
</IonContent>
</IonPage>
);
}

return (
<IonPage key="list" data-pageid="list-page">
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/content-change-navigation/home" />
</IonButtons>
<IonTitle>Item List</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<IonList>
{items.map((item, index) => (
<IonItem key={index}>
<IonLabel>{item}</IonLabel>
</IonItem>
))}
</IonList>
<br />
<IonButton onClick={clearItemsAndNavigate} data-testid="clear-and-navigate">
Remove all items and navigate to home
</IonButton>
</IonContent>
</IonPage>
);
};

const HomePage: React.FC = () => {
return (
<IonPage data-pageid="content-nav-home">
<IonHeader>
<IonToolbar>
<IonTitle>Home</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div data-testid="home-content">Home Page Content</div>
<IonButton routerLink="/content-change-navigation/list" data-testid="go-to-list">
Go to list page
</IonButton>
</IonContent>
</IonPage>
);
};

const ContentChangeNavigation: React.FC = () => {
return (
<IonRouterOutlet>
<Route index element={<Navigate to="home" replace />} />
<Route path="home" element={<HomePage />} />
<Route path="list" element={<ListPage />} />
</IonRouterOutlet>
);
};

export default ContentChangeNavigation;
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Tests for GitHub issue #28878
* https://github.com/ionic-team/ionic-framework/issues/28878
*
* Verifies that when view content changes (causing IonPage to remount)
* while navigation is happening, the correct view is displayed.
*/

const port = 3000;

describe('Content Change Navigation Tests (Issue #28878)', () => {
it('should navigate to list page correctly', () => {
cy.visit(`http://localhost:${port}/content-change-navigation`);
cy.ionPageVisible('content-nav-home');

cy.get('[data-testid="go-to-list"]').click();
cy.wait(300);

cy.ionPageVisible('list-page');
cy.url().should('include', '/content-change-navigation/list');
});

it('when clearing items and navigating, should show home page, not empty view', () => {
cy.visit(`http://localhost:${port}/content-change-navigation`);
cy.ionPageVisible('content-nav-home');

cy.get('[data-testid="go-to-list"]').click();
cy.wait(300);
cy.ionPageVisible('list-page');

// Bug scenario: clearing items renders a different IonPage while navigating away
cy.get('[data-testid="clear-and-navigate"]').click();
cy.wait(500);

cy.url().should('include', '/content-change-navigation/home');
cy.url().should('not.include', '/content-change-navigation/list');
cy.ionPageVisible('content-nav-home');
cy.get('[data-testid="home-content"]').should('be.visible');

// The empty view should NOT be visible (the fix ensures it's hidden)
cy.get('[data-testid="empty-view"]').should('not.be.visible');
});

it('direct navigation to home should work correctly', () => {
cy.visit(`http://localhost:${port}/content-change-navigation/home`);
cy.ionPageVisible('content-nav-home');
cy.get('[data-testid="home-content"]').should('be.visible');
});

it('direct navigation to list should work correctly', () => {
cy.visit(`http://localhost:${port}/content-change-navigation/list`);
cy.ionPageVisible('list-page');
cy.contains('Item 1').should('be.visible');
});
});
Loading