Skip to content

Commit 9dd2da5

Browse files
authored
fix(react-router): reject orphaned IonPage registrations during navigation (#30867)
Issue number: resolves #28878 --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? When a React component conditionally renders different `IonPage` structures based on state (e.g., a list view vs. an empty state view), and a state change happens simultaneously with navigation, the wrong view is displayed. The URL shows the correct destination route, but the stale/orphaned `IonPage` from the re-rendered component remains visible instead. Additionally, swipe-to-go-back gestures fail to properly unmount the leaving view, causing pages to remain in the DOM and preventing correct navigation. ## What is the new behavior? When an `IonPage` registration occurs for a route we're navigating away from, and the new page has a different `data-pageid` than the existing page element, the registration is rejected. The orphaned page is hidden and scheduled for removal, ensuring the correct destination view is displayed. Cross-outlet swipe back now works correctly by searching all outlets when the target view isn't found in the current outlet. ## Does this introduce a breaking change? - [ ] Yes - [X] No <!-- If this introduces a breaking change: 1. Describe the impact and migration path for existing applications below. 2. Update the BREAKING.md file with the breaking change. 3. Add "BREAKING CHANGE: [...]" to the commit description when merging. See https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer for more information. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> Current dev build: ``` 8.7.13-dev.11765829391.14bc580c ```
1 parent 60cedf0 commit 9dd2da5

File tree

5 files changed

+403
-27
lines changed

5 files changed

+403
-27
lines changed

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

Lines changed: 235 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,40 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
207207
}
208208

209209
if (routeInfo.routeAction === 'replace') {
210-
return true;
210+
const enteringRoutePath = enteringViewItem?.reactElement?.props?.path as string | undefined;
211+
const leavingRoutePath = leavingViewItem?.reactElement?.props?.path as string | undefined;
212+
213+
// Never unmount the root path "/" - it's the main entry point for back navigation
214+
if (leavingRoutePath === '/' || leavingRoutePath === '') {
215+
return false;
216+
}
217+
218+
if (enteringRoutePath && leavingRoutePath) {
219+
// Get parent paths to check if routes share a common parent
220+
const getParentPath = (path: string) => {
221+
const normalized = path.replace(/\/\*$/, ''); // Remove trailing /*
222+
const lastSlash = normalized.lastIndexOf('/');
223+
return lastSlash > 0 ? normalized.substring(0, lastSlash) : '/';
224+
};
225+
226+
const enteringParent = getParentPath(enteringRoutePath);
227+
const leavingParent = getParentPath(leavingRoutePath);
228+
229+
// Unmount if:
230+
// 1. Routes are siblings (same parent, e.g., /page1 and /page2, or /foo/page1 and /foo/page2)
231+
// 2. Entering is a child of leaving (redirect, e.g., /tabs -> /tabs/tab1)
232+
const areSiblings = enteringParent === leavingParent && enteringParent !== '/';
233+
const isChildRedirect =
234+
enteringRoutePath.startsWith(leavingRoutePath) ||
235+
(leavingRoutePath.endsWith('/*') && enteringRoutePath.startsWith(leavingRoutePath.slice(0, -2)));
236+
237+
return areSiblings || isChildRedirect;
238+
}
239+
240+
return false;
211241
}
212242

243+
// For non-replace actions, only unmount for back navigation (not forward push)
213244
const isForwardPush = routeInfo.routeAction === 'push' && (routeInfo as any).routeDirection === 'forward';
214245
if (!isForwardPush && routeInfo.routeDirection !== 'none' && enteringViewItem !== leavingViewItem) {
215246
return true;
@@ -317,9 +348,6 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
317348
leavingViewItem: ViewItem | undefined,
318349
shouldUnmountLeavingViewItem: boolean
319350
): void {
320-
// Ensure the entering view is not hidden from previous navigations
321-
showIonPageElement(enteringViewItem.ionPageElement);
322-
323351
// Handle same view item case (e.g., parameterized route changes)
324352
if (enteringViewItem === leavingViewItem) {
325353
const routePath = enteringViewItem.reactElement?.props?.path as string | undefined;
@@ -348,34 +376,93 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
348376
leavingViewItem = this.context.findViewItemByPathname(this.props.routeInfo.prevRouteLastPathname, this.id);
349377
}
350378

351-
// Skip transition if entering view is visible and leaving view is not
352-
if (
353-
enteringViewItem.ionPageElement &&
354-
isViewVisible(enteringViewItem.ionPageElement) &&
355-
leavingViewItem !== undefined &&
356-
leavingViewItem.ionPageElement &&
357-
!isViewVisible(leavingViewItem.ionPageElement)
358-
) {
359-
return;
379+
// Ensure the entering view is marked as mounted.
380+
// This is critical for views that were previously unmounted (e.g., navigating back to home).
381+
// When mount=false, the ViewLifeCycleManager doesn't render the IonPage, so the
382+
// ionPageElement reference becomes stale. By setting mount=true, we ensure the view
383+
// gets re-rendered and a new IonPage is created.
384+
if (!enteringViewItem.mount) {
385+
enteringViewItem.mount = true;
360386
}
361387

388+
// Check visibility state BEFORE showing the entering view.
389+
// This must be done before showIonPageElement to get accurate visibility state.
390+
const enteringWasVisible = enteringViewItem.ionPageElement && isViewVisible(enteringViewItem.ionPageElement);
391+
const leavingIsHidden =
392+
leavingViewItem !== undefined && leavingViewItem.ionPageElement && !isViewVisible(leavingViewItem.ionPageElement);
393+
362394
// Check for duplicate transition
363395
const currentTransition = {
364396
enteringId: enteringViewItem.id,
365397
leavingId: leavingViewItem?.id,
366398
};
367399

368-
if (
400+
const isDuplicateTransition =
369401
leavingViewItem &&
370402
this.lastTransition &&
371403
this.lastTransition.leavingId &&
372404
this.lastTransition.enteringId === currentTransition.enteringId &&
373-
this.lastTransition.leavingId === currentTransition.leavingId
374-
) {
405+
this.lastTransition.leavingId === currentTransition.leavingId;
406+
407+
// Skip transition if entering view was ALREADY visible and leaving view is not visible.
408+
// This indicates the transition has already been performed (e.g., via swipe gesture).
409+
// IMPORTANT: Only skip if both ionPageElements are the same as when the transition was last done.
410+
// If the leaving view's ionPageElement changed (e.g., component re-rendered with different IonPage),
411+
// we should NOT skip because the DOM state is inconsistent.
412+
if (enteringWasVisible && leavingIsHidden && isDuplicateTransition) {
413+
// For swipe-to-go-back, the transition animation was handled by the gesture.
414+
// We still need to set mount=false so React unmounts the leaving view.
415+
// Only do this when skipTransition is set (indicating gesture completion).
416+
if (
417+
this.skipTransition &&
418+
shouldUnmountLeavingViewItem &&
419+
leavingViewItem &&
420+
enteringViewItem !== leavingViewItem
421+
) {
422+
leavingViewItem.mount = false;
423+
// Call transitionPage with duration 0 to trigger ionViewDidLeave lifecycle
424+
// which is needed for ViewLifeCycleManager to remove the view.
425+
this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back');
426+
}
427+
// Clear skipTransition since we're not calling transitionPage which normally clears it
428+
this.skipTransition = false;
429+
// Must call forceUpdate to trigger re-render after mount state change
430+
this.forceUpdate();
431+
return;
432+
}
433+
434+
// Ensure the entering view is not hidden from previous navigations
435+
// This must happen AFTER the visibility check above
436+
showIonPageElement(enteringViewItem.ionPageElement);
437+
438+
// Skip if this is a duplicate transition (but visibility state didn't match above)
439+
// OR if skipTransition is set (swipe gesture already handled the animation)
440+
if (isDuplicateTransition || this.skipTransition) {
441+
// For swipe-to-go-back, we still need to handle unmounting even if visibility
442+
// conditions aren't fully met (animation might still be in progress)
443+
if (
444+
this.skipTransition &&
445+
shouldUnmountLeavingViewItem &&
446+
leavingViewItem &&
447+
enteringViewItem !== leavingViewItem
448+
) {
449+
leavingViewItem.mount = false;
450+
// For swipe-to-go-back, we need to call transitionPage with duration 0 to
451+
// trigger the ionViewDidLeave lifecycle event. The ViewLifeCycleManager
452+
// uses componentCanBeDestroyed callback to remove the view, which is
453+
// only called from ionViewDidLeave. Since the gesture animation already
454+
// completed before mount=false was set, we need to re-fire the lifecycle.
455+
this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back');
456+
}
457+
// Clear skipTransition since we're not calling transitionPage which normally clears it
458+
this.skipTransition = false;
459+
// Must call forceUpdate to trigger re-render after mount state change
460+
this.forceUpdate();
375461
return;
376462
}
377463

378464
this.lastTransition = currentTransition;
465+
379466
this.transitionPage(routeInfo, enteringViewItem, leavingViewItem);
380467

381468
// Handle unmounting the leaving view
@@ -386,14 +473,29 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
386473
}
387474

388475
/**
389-
* Handles the delayed unmount of the leaving view item after a replace action.
476+
* Handles the delayed unmount of the leaving view item.
477+
* For 'replace' actions: handles container route transitions specially.
478+
* For back navigation: explicitly unmounts because the ionViewDidLeave lifecycle
479+
* fires DURING transitionPage, but mount=false is set AFTER.
480+
*
481+
* @param routeInfo Current route information
482+
* @param enteringViewItem The view being navigated to
483+
* @param leavingViewItem The view being navigated from
390484
*/
391485
private handleLeavingViewUnmount(routeInfo: RouteInfo, enteringViewItem: ViewItem, leavingViewItem: ViewItem): void {
392-
if (routeInfo.routeAction !== 'replace' || !leavingViewItem.ionPageElement) {
486+
if (!leavingViewItem.ionPageElement) {
393487
return;
394488
}
395489

396-
// Check if we should skip removal for nested outlet redirects
490+
// For push/pop actions, do NOT unmount - views are cached for navigation history.
491+
// Push: Forward navigation caches views for back navigation
492+
// Pop: Back navigation should not unmount the entering view's history
493+
// Only 'replace' actions should actually unmount views since they replace history.
494+
if (routeInfo.routeAction !== 'replace') {
495+
return;
496+
}
497+
498+
// For replace actions, check if we should skip removal for nested outlet redirects
397499
const enteringRoutePath = enteringViewItem.reactElement?.props?.path as string | undefined;
398500
const leavingRoutePath = leavingViewItem.reactElement?.props?.path as string | undefined;
399501
const isEnteringContainerRoute = enteringRoutePath && enteringRoutePath.endsWith('/*');
@@ -412,6 +514,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
412514
const viewToUnmount = leavingViewItem;
413515
setTimeout(() => {
414516
this.context.unMountViewItem(viewToUnmount);
517+
// Trigger re-render to remove the view from DOM
518+
this.forceUpdate();
415519
}, VIEW_UNMOUNT_DELAY_MS);
416520
}
417521

@@ -472,6 +576,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
472576

473577
if (shouldUnmountLeavingViewItem && latestLeavingView && latestEnteringView !== latestLeavingView) {
474578
latestLeavingView.mount = false;
579+
// Call handleLeavingViewUnmount to ensure the view is properly removed
580+
this.handleLeavingViewUnmount(routeInfo, latestEnteringView, latestLeavingView);
475581
}
476582

477583
this.forceUpdate();
@@ -615,7 +721,14 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
615721
}
616722

617723
// Handle transition based on ion-page element availability
618-
if (enteringViewItem && enteringViewItem.ionPageElement) {
724+
// Check if the ionPageElement is still in the document.
725+
// If the view was previously unmounted (mount=false), the ViewLifeCycleManager
726+
// removes the React component from the tree, which removes the IonPage from the DOM.
727+
// The ionPageElement reference becomes stale and we need to wait for a new one.
728+
const ionPageIsInDocument =
729+
enteringViewItem?.ionPageElement && document.body.contains(enteringViewItem.ionPageElement);
730+
731+
if (enteringViewItem && ionPageIsInDocument) {
619732
// Clear waiting state
620733
if (this.waitingForIonPage) {
621734
this.waitingForIonPage = false;
@@ -626,8 +739,17 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
626739
}
627740

628741
this.handleReadyEnteringView(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem);
629-
} else if (enteringViewItem && !enteringViewItem.ionPageElement) {
742+
} else if (enteringViewItem && !ionPageIsInDocument) {
630743
// Wait for ion-page to mount
744+
// This handles both: no ionPageElement, or stale ionPageElement (not in document)
745+
// Clear stale reference if the element is no longer in the document
746+
if (enteringViewItem.ionPageElement && !document.body.contains(enteringViewItem.ionPageElement)) {
747+
enteringViewItem.ionPageElement = undefined;
748+
}
749+
// Ensure the view is marked as mounted so ViewLifeCycleManager renders the IonPage
750+
if (!enteringViewItem.mount) {
751+
enteringViewItem.mount = true;
752+
}
631753
this.handleWaitingForIonPage(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem);
632754
return;
633755
} else if (!enteringViewItem && !enteringRoute) {
@@ -657,9 +779,26 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
657779
this.ionPageWaitTimeout = undefined;
658780
}
659781
this.pendingPageTransition = false;
782+
660783
const foundView = this.context.findViewItemByRouteInfo(routeInfo, this.id);
784+
661785
if (foundView) {
662786
const oldPageElement = foundView.ionPageElement;
787+
788+
/**
789+
* FIX for issue #28878: Reject orphaned IonPage registrations.
790+
*
791+
* When a component conditionally renders different IonPages (e.g., list vs empty state)
792+
* using React keys, and state changes simultaneously with navigation, the new IonPage
793+
* tries to register for a route we're navigating away from. This creates a stale view.
794+
*
795+
* Only reject if both pageIds exist and differ, to allow nested outlet registrations.
796+
*/
797+
if (this.shouldRejectOrphanedPage(page, oldPageElement, routeInfo)) {
798+
this.hideAndRemoveOrphanedPage(page);
799+
return;
800+
}
801+
663802
foundView.ionPageElement = page;
664803
foundView.ionRoute = true;
665804

@@ -675,6 +814,45 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
675814
this.handlePageTransition(routeInfo);
676815
}
677816

817+
/**
818+
* Determines if a new IonPage registration should be rejected as orphaned.
819+
* This happens when a component re-renders with a different IonPage while navigating away.
820+
*/
821+
private shouldRejectOrphanedPage(
822+
newPage: HTMLElement,
823+
oldPageElement: HTMLElement | undefined,
824+
routeInfo: RouteInfo
825+
): boolean {
826+
if (!oldPageElement || oldPageElement === newPage) {
827+
return false;
828+
}
829+
830+
const newPageId = newPage.getAttribute('data-pageid');
831+
const oldPageId = oldPageElement.getAttribute('data-pageid');
832+
833+
// Only reject if both pageIds exist and are different
834+
if (!newPageId || !oldPageId || newPageId === oldPageId) {
835+
return false;
836+
}
837+
838+
// Reject only if we're navigating away from this route
839+
return this.props.routeInfo.pathname !== routeInfo.pathname;
840+
}
841+
842+
/**
843+
* Hides an orphaned IonPage and schedules its removal from the DOM.
844+
*/
845+
private hideAndRemoveOrphanedPage(page: HTMLElement): void {
846+
page.classList.add('ion-page-hidden');
847+
page.setAttribute('aria-hidden', 'true');
848+
849+
setTimeout(() => {
850+
if (page.parentElement) {
851+
page.remove();
852+
}
853+
}, VIEW_UNMOUNT_DELAY_MS);
854+
}
855+
678856
/**
679857
* Configures the router outlet for the swipe-to-go-back gesture.
680858
*
@@ -691,13 +869,28 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
691869

692870
const { routeInfo } = this.props;
693871
const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
694-
const enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
872+
// First try to find the view in the current outlet
873+
let enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
874+
// If not found in current outlet, search all outlets (for cross-outlet swipe back)
875+
if (!enteringViewItem) {
876+
enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, undefined, false);
877+
}
878+
879+
// Check if the ionPageElement is still in the document.
880+
// A view might have mount=false but still have its ionPageElement in the DOM
881+
// (due to timing differences in unmounting).
882+
const ionPageInDocument = Boolean(
883+
enteringViewItem?.ionPageElement && document.body.contains(enteringViewItem.ionPageElement)
884+
);
695885

696886
const canStartSwipe =
697887
!!enteringViewItem &&
698-
// The root url '/' is treated as the first view item (but is never mounted),
699-
// so we do not want to swipe back to the root url.
700-
enteringViewItem.mount &&
888+
// Check if we can swipe to this view. Either:
889+
// 1. The view is mounted (mount=true), OR
890+
// 2. The view's ionPageElement is still in the document
891+
// The second case handles views that have been marked for unmount but haven't
892+
// actually been removed from the DOM yet.
893+
(enteringViewItem.mount || ionPageInDocument) &&
701894
// When on the first page it is possible for findViewItemByRouteInfo to
702895
// return the exact same view you are currently on.
703896
// Make sure that we are not swiping back to the same instances of a view.
@@ -709,9 +902,20 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
709902
const onStart = async () => {
710903
const { routeInfo } = this.props;
711904
const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
712-
const enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
905+
// First try to find the view in the current outlet, then search all outlets
906+
let enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
907+
if (!enteringViewItem) {
908+
enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, undefined, false);
909+
}
713910
const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);
714911

912+
// Ensure the entering view is mounted so React keeps rendering it during the gesture.
913+
// This is important when the view was previously marked for unmount but its
914+
// ionPageElement is still in the DOM.
915+
if (enteringViewItem && !enteringViewItem.mount) {
916+
enteringViewItem.mount = true;
917+
}
918+
715919
// When the gesture starts, kick off a transition controlled via swipe gesture
716920
if (enteringViewItem && leavingViewItem) {
717921
await this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back', true);
@@ -729,7 +933,11 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
729933
// Swipe gesture was aborted - re-hide the page that was going to enter
730934
const { routeInfo } = this.props;
731935
const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
732-
const enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
936+
// First try to find the view in the current outlet, then search all outlets
937+
let enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
938+
if (!enteringViewItem) {
939+
enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, undefined, false);
940+
}
733941
const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);
734942

735943
// Don't hide if entering and leaving are the same (parameterized route edge case)

0 commit comments

Comments
 (0)