diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index f9c5f3f628..cb5c25f420 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -232,8 +232,7 @@ export class StackManager extends React.PureComponent { this.outOfScopeUnmountTimeout = undefined; } - const allViewsInOutlet = this.context.getViewItemsForOutlet ? this.context.getViewItemsForOutlet(this.id) : []; - + const allViewsInOutlet = this.context.getViewItemsForOutlet(this.id); allViewsInOutlet.forEach((viewItem) => { hideIonPageElement(viewItem.ionPageElement); this.context.unMountViewItem(viewItem); @@ -501,8 +500,7 @@ export class StackManager extends React.PureComponent { return; } - const allViewsInOutlet = this.context.getViewItemsForOutlet ? this.context.getViewItemsForOutlet(this.id) : []; - + const allViewsInOutlet = this.context.getViewItemsForOutlet(this.id); const areSiblingRoutes = (path1: string, path2: string): boolean => { const path1IsRelative = !path1.startsWith('/'); const path2IsRelative = !path2.startsWith('/'); @@ -569,8 +567,15 @@ export class StackManager extends React.PureComponent { } this.pendingPageTransition = false; - // Hide the leaving view immediately for Navigate redirects - hideIonPageElement(leavingViewItem?.ionPageElement); + // Hide ALL other visible views in this outlet for Navigate redirects. + // Same rationale as the timeout path: intermediate redirects can shift + // the leaving view reference, leaving the original page visible. + const allViewsInOutlet = this.context.getViewItemsForOutlet(this.id); + allViewsInOutlet.forEach((viewItem) => { + if (viewItem.id !== enteringViewItem.id && viewItem.ionPageElement) { + hideIonPageElement(viewItem.ionPageElement); + } + }); // Don't unmount if entering and leaving are the same view item if (shouldUnmountLeavingViewItem && leavingViewItem && enteringViewItem !== leavingViewItem) { @@ -617,11 +622,16 @@ export class StackManager extends React.PureComponent { /** * Timeout fired and entering view still has no ionPageElement. * This happens for container routes that render nested outlets without a direct IonPage. - * Hide the leaving view since there's no entering IonPage to wait for. + * Hide ALL other visible views in this outlet, not just the computed leaving view. + * This handles cases where intermediate redirects (e.g., Navigate in nested routes) + * change the leaving view reference, leaving the original page still visible. */ - if (latestLeavingView?.ionPageElement) { - hideIonPageElement(latestLeavingView.ionPageElement); - } + const allViewsInOutlet = this.context.getViewItemsForOutlet(this.id); + allViewsInOutlet.forEach((viewItem) => { + if (viewItem.id !== latestEnteringView.id && viewItem.ionPageElement) { + hideIonPageElement(viewItem.ionPageElement); + } + }); this.forceUpdate(); } }, ION_PAGE_WAIT_TIMEOUT_MS); @@ -688,7 +698,7 @@ export class StackManager extends React.PureComponent { // the nested outlet's componentDidUpdate won't be called, so we must hide // the ion-page elements here to prevent them from remaining visible on top // of other content after navigation to a different route. - const allViewsInOutlet = this.context.getViewItemsForOutlet ? this.context.getViewItemsForOutlet(this.id) : []; + const allViewsInOutlet = this.context.getViewItemsForOutlet(this.id); allViewsInOutlet.forEach((viewItem) => { hideIonPageElement(viewItem.ionPageElement); }); diff --git a/packages/react-router/test/base/tests/e2e/specs/nested-tabs-relative-links.cy.js b/packages/react-router/test/base/tests/e2e/specs/nested-tabs-relative-links.cy.js index dc375e1c83..563b45a5ca 100644 --- a/packages/react-router/test/base/tests/e2e/specs/nested-tabs-relative-links.cy.js +++ b/packages/react-router/test/base/tests/e2e/specs/nested-tabs-relative-links.cy.js @@ -100,6 +100,43 @@ describe('Nested Tabs with Relative Links', () => { cy.get('[data-testid="page-a-content"]').should('exist'); }); + /** + * This test navigates from the home page (/) to nested-tabs-relative-links + * via routerLink, rather than visiting the URL directly. + * + * This is a distinct scenario because NestedTabsRelativeLinks is a container + * route that does NOT wrap its content in IonPage — it renders IonRouterOutlet + * directly. When the root outlet transitions to a container without IonPage, + * the nested redirect fires before the root + * outlet's timeout, causing the leaving view reference to shift. Without + * proper handling, the home page is never hidden. + */ + it('should navigate from home page to nested tabs', () => { + cy.visit(`http://localhost:${port}/`); + cy.ionPageVisible('home'); + + // Click the Nested Tabs Relative Links item + cy.get('ion-item[router-link="/nested-tabs-relative-links"]').click(); + + // Should navigate to tab1 (via index redirect) + cy.ionPageVisible('nested-tabs-relative-tab1'); + cy.get('[data-testid="tab1-content"]').should('exist'); + + // Home page should be hidden + cy.ionPageHidden('home'); + + // URL should be correct + cy.url().should('include', '/nested-tabs-relative-links/tab1'); + + // Verify the container route does NOT have an IonPage wrapper. + // This is the key invariant: NestedTabsRelativeLinks renders IonRouterOutlet + // directly without IonPage. If someone adds IonPage to the container, this + // test would pass trivially and no longer cover the container-without-IonPage + // transition path. The ion-tabs element should have no [data-pageid] ancestors, + // confirming no IonPage wraps the container. + cy.get('ion-tabs').parents('[data-pageid]').should('have.length', 0); + }); + it('should switch tabs and maintain correct relative link resolution', () => { cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1`); cy.ionPageVisible('nested-tabs-relative-tab1');