diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index bf819266b1..c75d535d0e 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -470,6 +470,11 @@ export class StackManager extends React.PureComponent { leavingViewItem.mount = false; this.handleLeavingViewUnmount(routeInfo, enteringViewItem, leavingViewItem); } + + // Clean up any orphaned sibling views that are no longer reachable + // This is important for replace actions (like redirects) where sibling views + // that were pushed earlier become unreachable + this.cleanupOrphanedSiblingViews(routeInfo, enteringViewItem, leavingViewItem); } /** @@ -520,6 +525,90 @@ export class StackManager extends React.PureComponent { }, VIEW_UNMOUNT_DELAY_MS); } + /** + * Cleans up orphaned sibling views after a replace action. + * When navigating via replace (e.g., through a redirect), sibling views that were + * pushed earlier may become orphaned (unreachable via back navigation). + * This method identifies and unmounts such views. + */ + private cleanupOrphanedSiblingViews( + routeInfo: RouteInfo, + enteringViewItem: ViewItem, + leavingViewItem: ViewItem | undefined + ): void { + // Only cleanup for replace actions + if (routeInfo.routeAction !== 'replace') { + return; + } + + const enteringRoutePath = enteringViewItem.reactElement?.props?.path as string | undefined; + if (!enteringRoutePath) { + return; + } + + // Get all views in this outlet + const allViewsInOutlet = this.context.getViewItemsForOutlet ? this.context.getViewItemsForOutlet(this.id) : []; + + // Check if routes are "siblings" - direct children of the same outlet at the same level + const areSiblingRoutes = (path1: string, path2: string): boolean => { + // Both are relative routes (don't start with /) + const path1IsRelative = !path1.startsWith('/'); + const path2IsRelative = !path2.startsWith('/'); + + // For relative routes at the outlet root level, they're siblings + if (path1IsRelative && path2IsRelative) { + // Check if they're at the same depth (no nested slashes, except for wildcards) + const path1Depth = path1.replace(/\/\*$/, '').split('/').filter(Boolean).length; + const path2Depth = path2.replace(/\/\*$/, '').split('/').filter(Boolean).length; + return path1Depth === path2Depth && path1Depth <= 1; + } + + // For absolute routes, check if they share the same parent + const getParent = (path: string) => { + const normalized = path.replace(/\/\*$/, ''); + const lastSlash = normalized.lastIndexOf('/'); + return lastSlash > 0 ? normalized.substring(0, lastSlash) : '/'; + }; + + return getParent(path1) === getParent(path2); + }; + + for (const viewItem of allViewsInOutlet) { + const viewRoutePath = viewItem.reactElement?.props?.path as string | undefined; + + // Skip views that shouldn't be cleaned up: + // - The entering view itself + // - The immediate leaving view (handled separately by handleLeavingViewUnmount) + // - Already unmounted views + // - Views without a route path + // - Container routes (ending in /*) when entering is also a container route + const shouldSkip = + viewItem.id === enteringViewItem.id || + (leavingViewItem && viewItem.id === leavingViewItem.id) || + !viewItem.mount || + !viewRoutePath || + (viewRoutePath.endsWith('/*') && enteringRoutePath.endsWith('/*')); + + if (shouldSkip) { + continue; + } + + // Check if this is a sibling route that should be cleaned up + if (areSiblingRoutes(enteringRoutePath, viewRoutePath)) { + // Hide and unmount the orphaned view + hideIonPageElement(viewItem.ionPageElement); + viewItem.mount = false; + + // Schedule removal + const viewToRemove = viewItem; + setTimeout(() => { + this.context.unMountViewItem(viewToRemove); + this.forceUpdate(); + }, VIEW_UNMOUNT_DELAY_MS); + } + } + } + /** * Handles the case when entering view has no ion-page element yet (waiting for render). */ diff --git a/packages/react-router/test/base/tests/e2e/specs/routing.cy.js b/packages/react-router/test/base/tests/e2e/specs/routing.cy.js index b9645ee491..d7a47cedf4 100644 --- a/packages/react-router/test/base/tests/e2e/specs/routing.cy.js +++ b/packages/react-router/test/base/tests/e2e/specs/routing.cy.js @@ -268,7 +268,7 @@ describe('Routing Tests', () => { cy.ionPageVisible('home-details-page-2'); }); - it('/routing/tabs/home Menu > Favorites > Menu > Home with redirect, Home page should be visible, and Favorites should be hidden', () => { + it('/routing/tabs/home Menu > Favorites > Menu > Home with redirect, Home page should be visible, and Favorites should be destroyed', () => { cy.visit(`http://localhost:${port}/routing/tabs/home`); cy.ionMenuClick(); cy.ionMenuNav('Favorites'); @@ -276,10 +276,10 @@ describe('Routing Tests', () => { cy.ionMenuClick(); cy.ionMenuNav('Home with redirect'); cy.ionPageVisible('home-page'); - cy.ionPageHidden('favorites-page'); + cy.ionPageDoesNotExist('favorites-page'); }); - it('/routing/tabs/home Menu > Favorites > Menu > Home with router, Home page should be visible, and Favorites should be hidden', () => { + it('/routing/tabs/home Menu > Favorites > Menu > Home with router, Home page should be visible, and Favorites should be destroyed', () => { cy.visit(`http://localhost:${port}/routing/tabs/home`); cy.ionMenuClick(); cy.ionMenuNav('Favorites'); @@ -287,7 +287,7 @@ describe('Routing Tests', () => { cy.ionMenuClick(); cy.ionMenuNav('Home with router'); cy.ionPageVisible('home-page'); - cy.ionPageHidden('favorites-page'); + cy.ionPageDoesNotExist('favorites-page'); }); it('should show back button when going back to a pushed page', () => {