fix(react-router): nested redirect fix

This commit is contained in:
ShaneK
2025-11-26 09:53:59 -08:00
parent b02c197b9e
commit 7fd0659856
3 changed files with 71 additions and 3 deletions

View File

@@ -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 =

View File

@@ -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<StackManagerProps, StackMa
* Set a flag to indicate that we should transition the page after
* the component has updated (i.e., in `componentDidUpdate`).
*/
if (process.env.NODE_ENV !== 'production') {
console.log(`[StackManager] Outlet ${this.id} not ready yet, setting pendingPageTransition=true for ${routeInfo.pathname}`);
}
this.pendingPageTransition = true;
} else {
let enteringViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id);
let leavingViewItem = this.context.findLeavingViewItemByRouteInfo(routeInfo, this.id);
if (process.env.NODE_ENV !== 'production') {
console.log(`[StackManager:handlePageTransition] outlet=${this.id}, pathname=${routeInfo.pathname}, entering=${enteringViewItem?.id}, leaving=${leavingViewItem?.id}, enteringIsNav=${isNavigateViewItem(enteringViewItem)}, leavingIsNav=${isNavigateViewItem(leavingViewItem)}`);
}
/**
* If we don't have a leaving view item, but the route info indicates
* that the user has routed from a previous path, then the leaving view
@@ -425,7 +443,13 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa
: [];
// Unmount and remove all views in this outlet immediately to avoid leftover content
if (process.env.NODE_ENV !== 'production') {
console.log(`[StackManager:outOfScope] outlet=${this.id} is out of scope, removing ${allViewsInOutlet.length} views`);
}
allViewsInOutlet.forEach((viewItem) => {
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<StackManagerProps, StackMa
leavingViewItem.ionPageElement.setAttribute('aria-hidden', 'true');
}
if (leavingViewItem) {
if (isNavigateViewItem(leavingViewItem) && process.env.NODE_ENV !== 'production') {
console.log(`[StackManager:hasRelativeRoutes] Setting mount=false on Navigate ${leavingViewItem.id}, outlet=${this.id}, pathname=${routeInfo.pathname}`);
}
leavingViewItem.mount = false;
}
this.forceUpdate();
@@ -492,6 +519,9 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa
// If this is a nested outlet (has an explicit ID) and no route matches,
// it means this outlet shouldn't handle this route
if (this.id !== 'routerOutlet' && !enteringRoute && !enteringViewItem) {
if (process.env.NODE_ENV !== 'production') {
console.log(`[StackManager:noMatchingRoute] outlet=${this.id} has no matching route for ${routeInfo.pathname}, leavingViewItem=${leavingViewItem?.id}`);
}
// Hide any visible views in this outlet since it has no matching route
if (leavingViewItem && leavingViewItem.ionPageElement) {
leavingViewItem.ionPageElement.classList.add('ion-page-hidden');
@@ -499,6 +529,9 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa
}
// Unmount the leaving view to prevent components from staying active
if (leavingViewItem) {
if (isNavigateViewItem(leavingViewItem) && process.env.NODE_ENV !== 'production') {
console.log(`[StackManager:noMatchingRoute] Setting mount=false on Navigate ${leavingViewItem.id}, outlet=${this.id}, pathname=${routeInfo.pathname}`);
}
leavingViewItem.mount = false;
}
this.forceUpdate();
@@ -512,9 +545,18 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa
if (enteringViewItem && enteringRoute) {
// Update existing view item
enteringViewItem.reactElement = enteringRoute;
if (process.env.NODE_ENV !== 'production') {
console.log(`[StackManager] Updated existing view item ${enteringViewItem.id} for outlet ${this.id}`);
}
} else if (enteringRoute) {
if (process.env.NODE_ENV !== 'production') {
console.log(`[StackManager] Creating new view item for outlet ${this.id}, route path="${enteringRoute.props?.path ?? '(index)'}"`);
}
enteringViewItem = this.context.createViewItem(this.id, enteringRoute, routeInfo);
this.context.addViewItem(enteringViewItem);
if (process.env.NODE_ENV !== 'production') {
console.log(`[StackManager] Added view item ${enteringViewItem.id} to outlet ${this.id}`);
}
}
/**
@@ -674,6 +716,9 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa
* and repeatedly hide the leaving view. Treat this as a no-op transition and allow
* the follow-up navigation to proceed.
*/
if (process.env.NODE_ENV !== 'production') {
console.log(`[StackManager:Navigate] outlet=${this.id}, entering=${enteringViewItem?.id}, leaving=${leavingViewItem?.id}, shouldUnmount=${shouldUnmountLeavingViewItem}, pathname=${routeInfo.pathname}`);
}
this.waitingForIonPage = false;
if (this.ionPageWaitTimeout) {
clearTimeout(this.ionPageWaitTimeout);
@@ -686,7 +731,13 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa
leavingViewItem.ionPageElement.classList.add('ion-page-hidden');
leavingViewItem.ionPageElement.setAttribute('aria-hidden', 'true');
}
if (shouldUnmountLeavingViewItem && leavingViewItem) {
// IMPORTANT: Don't unmount if entering and leaving are the same view item
// This happens during chained Navigate redirects where the same Navigate view item
// is being processed multiple times before it can render and trigger the redirect
if (shouldUnmountLeavingViewItem && leavingViewItem && enteringViewItem !== leavingViewItem) {
if (isNavigateViewItem(leavingViewItem) && process.env.NODE_ENV !== 'production') {
console.log(`[StackManager:Navigate:unmountLeaving] Setting mount=false on Navigate ${leavingViewItem.id}, outlet=${this.id}`);
}
leavingViewItem.mount = false;
}

View File

@@ -345,6 +345,16 @@ describe('Routing Tests', () => {
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