Skip to content

Commit 2929474

Browse files
committed
fix(react-router): cleanup orphaned sibling views after replace navigation
1 parent 289f6ed commit 2929474

File tree

2 files changed

+99
-4
lines changed

2 files changed

+99
-4
lines changed

packages/react-router/src/ReactRouter/StackManager.tsx

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,11 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
470470
leavingViewItem.mount = false;
471471
this.handleLeavingViewUnmount(routeInfo, enteringViewItem, leavingViewItem);
472472
}
473+
474+
// Clean up any orphaned sibling views that are no longer reachable
475+
// This is important for replace actions (like redirects) where sibling views
476+
// that were pushed earlier become unreachable
477+
this.cleanupOrphanedSiblingViews(routeInfo, enteringViewItem, leavingViewItem);
473478
}
474479

475480
/**
@@ -520,6 +525,96 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
520525
}, VIEW_UNMOUNT_DELAY_MS);
521526
}
522527

528+
/**
529+
* Cleans up orphaned sibling views after a replace action.
530+
* When navigating via replace (e.g., through a redirect), sibling views that were
531+
* pushed earlier may become orphaned (unreachable via back navigation).
532+
* This method identifies and unmounts such views.
533+
*/
534+
private cleanupOrphanedSiblingViews(
535+
routeInfo: RouteInfo,
536+
enteringViewItem: ViewItem,
537+
leavingViewItem: ViewItem | undefined
538+
): void {
539+
// Only cleanup for replace actions
540+
if (routeInfo.routeAction !== 'replace') {
541+
return;
542+
}
543+
544+
const enteringRoutePath = enteringViewItem.reactElement?.props?.path as string | undefined;
545+
if (!enteringRoutePath) {
546+
return;
547+
}
548+
549+
// Get all views in this outlet
550+
const allViewsInOutlet = this.context.getViewItemsForOutlet ? this.context.getViewItemsForOutlet(this.id) : [];
551+
552+
// Check if routes are "siblings" - direct children of the same outlet at the same level
553+
const areSiblingRoutes = (path1: string, path2: string): boolean => {
554+
// Both are relative routes (don't start with /)
555+
const path1IsRelative = !path1.startsWith('/');
556+
const path2IsRelative = !path2.startsWith('/');
557+
558+
// For relative routes at the outlet root level, they're siblings
559+
if (path1IsRelative && path2IsRelative) {
560+
// Check if they're at the same depth (no nested slashes, except for wildcards)
561+
const path1Depth = path1.replace(/\/\*$/, '').split('/').filter(Boolean).length;
562+
const path2Depth = path2.replace(/\/\*$/, '').split('/').filter(Boolean).length;
563+
return path1Depth === path2Depth && path1Depth <= 1;
564+
}
565+
566+
// For absolute routes, check if they share the same parent
567+
const getParent = (path: string) => {
568+
const normalized = path.replace(/\/\*$/, '');
569+
const lastSlash = normalized.lastIndexOf('/');
570+
return lastSlash > 0 ? normalized.substring(0, lastSlash) : '/';
571+
};
572+
573+
return getParent(path1) === getParent(path2);
574+
};
575+
576+
for (const viewItem of allViewsInOutlet) {
577+
// Skip the entering view
578+
if (viewItem.id === enteringViewItem.id) {
579+
continue;
580+
}
581+
582+
// Skip the immediate leaving view (handled separately)
583+
if (leavingViewItem && viewItem.id === leavingViewItem.id) {
584+
continue;
585+
}
586+
587+
// Skip if already unmounted
588+
if (!viewItem.mount) {
589+
continue;
590+
}
591+
592+
const viewRoutePath = viewItem.reactElement?.props?.path as string | undefined;
593+
if (!viewRoutePath) {
594+
continue;
595+
}
596+
597+
// Skip container routes (ending in /*) - they manage their own children
598+
if (viewRoutePath.endsWith('/*') && enteringRoutePath.endsWith('/*')) {
599+
continue;
600+
}
601+
602+
// Check if this is a sibling route that should be cleaned up
603+
if (areSiblingRoutes(enteringRoutePath, viewRoutePath)) {
604+
// Hide and unmount the orphaned view
605+
hideIonPageElement(viewItem.ionPageElement);
606+
viewItem.mount = false;
607+
608+
// Schedule removal
609+
const viewToRemove = viewItem;
610+
setTimeout(() => {
611+
this.context.unMountViewItem(viewToRemove);
612+
this.forceUpdate();
613+
}, VIEW_UNMOUNT_DELAY_MS);
614+
}
615+
}
616+
}
617+
523618
/**
524619
* Handles the case when entering view has no ion-page element yet (waiting for render).
525620
*/

packages/react-router/test/base/tests/e2e/specs/routing.cy.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -268,26 +268,26 @@ describe('Routing Tests', () => {
268268
cy.ionPageVisible('home-details-page-2');
269269
});
270270

271-
it('/routing/tabs/home Menu > Favorites > Menu > Home with redirect, Home page should be visible, and Favorites should be hidden', () => {
271+
it('/routing/tabs/home Menu > Favorites > Menu > Home with redirect, Home page should be visible, and Favorites should be destroyed', () => {
272272
cy.visit(`http://localhost:${port}/routing/tabs/home`);
273273
cy.ionMenuClick();
274274
cy.ionMenuNav('Favorites');
275275
cy.ionPageVisible('favorites-page');
276276
cy.ionMenuClick();
277277
cy.ionMenuNav('Home with redirect');
278278
cy.ionPageVisible('home-page');
279-
cy.ionPageHidden('favorites-page');
279+
cy.ionPageDoesNotExist('favorites-page');
280280
});
281281

282-
it('/routing/tabs/home Menu > Favorites > Menu > Home with router, Home page should be visible, and Favorites should be hidden', () => {
282+
it('/routing/tabs/home Menu > Favorites > Menu > Home with router, Home page should be visible, and Favorites should be destroyed', () => {
283283
cy.visit(`http://localhost:${port}/routing/tabs/home`);
284284
cy.ionMenuClick();
285285
cy.ionMenuNav('Favorites');
286286
cy.ionPageVisible('favorites-page');
287287
cy.ionMenuClick();
288288
cy.ionMenuNav('Home with router');
289289
cy.ionPageVisible('home-page');
290-
cy.ionPageHidden('favorites-page');
290+
cy.ionPageDoesNotExist('favorites-page');
291291
});
292292

293293
it('should show back button when going back to a pushed page', () => {

0 commit comments

Comments
 (0)