Skip to content

Commit ab3dde2

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

File tree

2 files changed

+93
-4
lines changed

2 files changed

+93
-4
lines changed

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

Lines changed: 89 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,90 @@ 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+
const viewRoutePath = viewItem.reactElement?.props?.path as string | undefined;
578+
579+
// Skip views that shouldn't be cleaned up:
580+
// - The entering view itself
581+
// - The immediate leaving view (handled separately by handleLeavingViewUnmount)
582+
// - Already unmounted views
583+
// - Views without a route path
584+
// - Container routes (ending in /*) when entering is also a container route
585+
const shouldSkip =
586+
viewItem.id === enteringViewItem.id ||
587+
(leavingViewItem && viewItem.id === leavingViewItem.id) ||
588+
!viewItem.mount ||
589+
!viewRoutePath ||
590+
(viewRoutePath.endsWith('/*') && enteringRoutePath.endsWith('/*'));
591+
592+
if (shouldSkip) {
593+
continue;
594+
}
595+
596+
// Check if this is a sibling route that should be cleaned up
597+
if (areSiblingRoutes(enteringRoutePath, viewRoutePath)) {
598+
// Hide and unmount the orphaned view
599+
hideIonPageElement(viewItem.ionPageElement);
600+
viewItem.mount = false;
601+
602+
// Schedule removal
603+
const viewToRemove = viewItem;
604+
setTimeout(() => {
605+
this.context.unMountViewItem(viewToRemove);
606+
this.forceUpdate();
607+
}, VIEW_UNMOUNT_DELAY_MS);
608+
}
609+
}
610+
}
611+
523612
/**
524613
* Handles the case when entering view has no ion-page element yet (waiting for render).
525614
*/

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)