diff --git a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx index 0982258ea7..d3dc8e537f 100644 --- a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx +++ b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx @@ -339,7 +339,7 @@ export class ReactRouterViewStack extends ViewStacks { * - Wraps the route element in to support nested routing and ensure remounting * - Adds a unique key to so React Router remounts routes when switching */ - private renderViewItem = (viewItem: ViewItem, routeInfo: RouteInfo, parentPath?: string) => { + private renderViewItem = (viewItem: ViewItem, routeInfo: RouteInfo, parentPath?: string, reRender?: () => void) => { const routePath = viewItem.reactElement.props.path || ''; let match = matchComponent(viewItem.reactElement, routeInfo.pathname); @@ -383,6 +383,7 @@ export class ReactRouterViewStack extends ViewStacks { // This ensures the redirect completes before removal setTimeout(() => { this.remove(viewItem); + reRender?.(); }, NAVIGATE_REDIRECT_DELAY_MS); } } @@ -409,6 +410,7 @@ export class ReactRouterViewStack extends ViewStacks { const stillNotNeeded = !viewItem.mount && !viewItem.ionPageElement; if (stillNotNeeded) { this.remove(viewItem); + reRender?.(); } }, VIEW_CLEANUP_DELAY_MS); } else { @@ -522,7 +524,12 @@ export class ReactRouterViewStack extends ViewStacks { * 3. Returns a list of React components that will be rendered inside the outlet * Each view is wrapped in to manage lifecycle and rendering */ - getChildrenToRender = (outletId: string, ionRouterOutlet: React.ReactElement, routeInfo: RouteInfo) => { + getChildrenToRender = ( + outletId: string, + ionRouterOutlet: React.ReactElement, + routeInfo: RouteInfo, + reRender: () => void + ) => { const viewItems = this.getViewItemsForOutlet(outletId); // Determine parentPath for nested outlets to properly evaluate index routes @@ -621,6 +628,7 @@ export class ReactRouterViewStack extends ViewStacks { // View is outside current route hierarchy, remove it setTimeout(() => { this.remove(viewItem); + reRender(); }, 0); return false; } @@ -630,7 +638,9 @@ export class ReactRouterViewStack extends ViewStacks { return true; }); - const renderedItems = renderableViewItems.map((viewItem) => this.renderViewItem(viewItem, routeInfo, parentPath)); + const renderedItems = renderableViewItems.map((viewItem) => + this.renderViewItem(viewItem, routeInfo, parentPath, reRender) + ); return renderedItems; }; diff --git a/packages/react-router/test/base/src/App.tsx b/packages/react-router/test/base/src/App.tsx index c2382eda9c..bfe95a9222 100644 --- a/packages/react-router/test/base/src/App.tsx +++ b/packages/react-router/test/base/src/App.tsx @@ -49,6 +49,7 @@ import ContentChangeNavigation from './pages/content-change-navigation/ContentCh import SearchParams from './pages/search-params/SearchParams'; import IonRoutePropsTest from './pages/ion-route-props/IonRouteProps'; import PrefixMatchWildcard from './pages/prefix-match-wildcard/PrefixMatchWildcard'; +import StaleViewCleanup from './pages/stale-view-cleanup/StaleViewCleanup'; setupIonicReact(); @@ -87,6 +88,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> diff --git a/packages/react-router/test/base/src/pages/Main.tsx b/packages/react-router/test/base/src/pages/Main.tsx index 2d3659811b..fd1ad7a13c 100644 --- a/packages/react-router/test/base/src/pages/Main.tsx +++ b/packages/react-router/test/base/src/pages/Main.tsx @@ -92,6 +92,9 @@ const Main: React.FC = () => { Prefix Match Wildcard + + Stale View Cleanup + diff --git a/packages/react-router/test/base/src/pages/stale-view-cleanup/StaleViewCleanup.tsx b/packages/react-router/test/base/src/pages/stale-view-cleanup/StaleViewCleanup.tsx new file mode 100644 index 0000000000..c391ff0c72 --- /dev/null +++ b/packages/react-router/test/base/src/pages/stale-view-cleanup/StaleViewCleanup.tsx @@ -0,0 +1,39 @@ +import { IonRouterOutlet } from '@ionic/react'; +import React from 'react'; +import { Route, useNavigate } from 'react-router-dom'; + +/** + * A component without IonPage wrapper. + * When navigated away from, this should be cleaned up from the DOM. + */ +const NonIonPageSource: React.FC = () => { + const navigate = useNavigate(); + + return ( +
+

Non-IonPage Source

+ +
+ ); +}; + +const NonIonPageTarget: React.FC = () => { + return ( +
+
Target page loaded
+
+ ); +}; + +const StaleViewCleanup: React.FC = () => { + return ( + + } /> + } /> + + ); +}; + +export default StaleViewCleanup; diff --git a/packages/react-router/test/base/tests/e2e/specs/stale-view-cleanup.cy.js b/packages/react-router/test/base/tests/e2e/specs/stale-view-cleanup.cy.js new file mode 100644 index 0000000000..65d6bd06b0 --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/specs/stale-view-cleanup.cy.js @@ -0,0 +1,24 @@ +const port = 3000; + +describe('Stale View Cleanup', () => { + /** + * Tests that non-IonPage components are properly cleaned up from the DOM + * after navigating away. Both source and target lack IonPage wrappers. + * Guards against regressions in the view stack cleanup logic. + */ + it('should clean up stale non-IonPage view after navigating to another non-IonPage view', () => { + cy.visit(`http://localhost:${port}/stale-view-cleanup/non-ionpage`); + + // Verify the non-IonPage source component is visible + cy.get('[data-testid="non-ionpage-source"]').should('exist'); + + // Navigate to the target (also non-IonPage) + cy.get('#go-to-target').click(); + + // Verify the target loaded + cy.get('[data-testid="target-loaded"]').should('exist'); + + // The non-IonPage source component should be cleaned up from the DOM + cy.get('[data-testid="non-ionpage-source"]').should('not.exist'); + }); +});