From 7fd0659856974816346041939d08420fc38afdc3 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 26 Nov 2025 09:53:59 -0800 Subject: [PATCH] fix(react-router): nested redirect fix --- .../src/ReactRouter/ReactRouterViewStack.tsx | 11 +++- .../src/ReactRouter/StackManager.tsx | 53 ++++++++++++++++++- .../test/base/tests/e2e/specs/routing.cy.js | 10 ++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx index 72297838ce..5b39504997 100644 --- a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx +++ b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx @@ -275,7 +275,12 @@ export class ReactRouterViewStack extends ViewStacks { if (isNavigateComponent) { // Navigate components should only be mounted when they match // Once they redirect (no longer match), they should be removed completely - if (!match && viewItem.mount) { + // IMPORTANT: For index routes, we need to check indexMatch too since matchComponent + // may not properly match index routes without explicit parent path context + const indexMatch = viewItem.routeData?.childProps?.index ? resolveIndexRouteMatch(viewItem, routeInfo.pathname, parentPath) : null; + const hasValidMatch = match || indexMatch; + + if (!hasValidMatch && viewItem.mount) { viewItem.mount = false; // Schedule removal of the Navigate view item after a short delay // This ensures the redirect completes before removal @@ -288,7 +293,9 @@ export class ReactRouterViewStack extends ViewStacks { // Components that don't have IonPage elements and no longer match should be cleaned up // BUT we need to be careful not to remove them if they're part of browser navigation history // This handles components that perform immediate actions like programmatic navigation - if (!match && viewItem.mount && !viewItem.ionPageElement) { + // EXCEPTION: Navigate components should ALWAYS remain mounted until they redirect + // since they need to be rendered to trigger the navigation + if (!match && viewItem.mount && !viewItem.ionPageElement && !isNavigateComponent) { // Check if this view item should be preserved for browser navigation // We'll keep it if it was recently active (within the last navigation) const shouldPreserve = diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index 002658b07c..1d2afcd6f7 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -14,6 +14,17 @@ import { findRoutesNode } from './utils/findRoutesNode'; import { derivePathnameToMatch } from './utils/derivePathnameToMatch'; import { matchPath } from './utils/matchPath'; +// Debug helper to check if a view item contains a Navigate component +const isNavigateViewItem = (viewItem: ViewItem | undefined): boolean => { + if (!viewItem) return false; + const elementComponent = viewItem.reactElement?.props?.element; + return ( + React.isValidElement(elementComponent) && + (elementComponent.type === Navigate || + (typeof elementComponent.type === 'function' && elementComponent.type.name === 'Navigate')) + ); +}; + /** * Checks if a route matches the remaining path. * Note: This function is used for checking if ANY route could match, not for determining priority. @@ -320,11 +331,18 @@ export class StackManager extends React.PureComponent { + if (process.env.NODE_ENV !== 'production') { + console.log(`[StackManager:outOfScope] Removing view ${viewItem.id} from outlet ${this.id}, isNavigate=${isNavigateViewItem(viewItem)}`); + } if (viewItem.ionPageElement) { viewItem.ionPageElement.classList.add('ion-page-hidden'); viewItem.ionPageElement.setAttribute('aria-hidden', 'true'); @@ -466,6 +490,9 @@ export class StackManager extends React.PureComponent { cy.get('div.ion-page[data-pageid=home-details-page-1] [data-testid="details-input"]').should('have.value', '1'); }); + it('should complete chained Navigate redirects from root to /routing/tabs/home', () => { + // Tests that chained Navigate redirects work correctly: + // / > click Routing link > /routing (Navigate to tabs) > /routing/tabs (Navigate to home) > /routing/tabs/home + // This was a bug where the second Navigate would be unmounted before it could trigger + cy.visit(`http://localhost:${port}/`); + cy.ionNav('ion-item', 'Routing'); + cy.ionPageVisible('home-page'); + cy.url().should('include', '/routing/tabs/home'); + }); + /* Tests to add: Test that lifecycle events fire