Skip to content

Commit b5ff48d

Browse files
committed
fix(react-router): use location keys for forward detection and prevent overlap in nested outlets
1 parent 28f94d8 commit b5ff48d

3 files changed

Lines changed: 282 additions & 16 deletions

File tree

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

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,20 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr
8383
const viewStack = useRef(new ReactRouterViewStack());
8484
const incomingRouteParams = useRef<Partial<RouteInfo> | null>(null);
8585
/**
86-
* Tracks URLs (pathname + search) that the user navigated away from via
87-
* browser back. When a POP event's destination matches the top of this
88-
* stack, it's a browser forward navigation. Cleared on PUSH (new
89-
* navigation invalidates forward history, just like in the browser).
86+
* Tracks location keys that the user navigated away from via browser back.
87+
* When a POP event's destination key matches the top of this stack, it's a
88+
* browser forward navigation. Uses React Router's unique location.key
89+
* instead of URLs to correctly handle duplicate URLs in history (e.g.,
90+
* navigating to /details, then /settings, then /details via routerLink,
91+
* then pressing back).
92+
* Cleared on PUSH (new navigation invalidates forward history).
9093
*/
9194
const forwardStack = useRef<string[]>([]);
95+
/**
96+
* Tracks the current location key so we can push it onto the forward stack
97+
* when navigating back. Updated after each history change.
98+
*/
99+
const currentLocationKeyRef = useRef<string>(location.key);
92100

93101
const [routeInfo, setRouteInfo] = useState<RouteInfo>({
94102
id: generateId('routeInfo'),
@@ -203,7 +211,7 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr
203211
const currentRoute = locationHistory.current.current();
204212
const isForwardNavigation =
205213
forwardStack.current.length > 0 &&
206-
forwardStack.current[forwardStack.current.length - 1] === location.pathname + location.search;
214+
forwardStack.current[forwardStack.current.length - 1] === location.key;
207215

208216
if (isForwardNavigation) {
209217
forwardStack.current.pop();
@@ -213,8 +221,8 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr
213221
tab: tabToUse,
214222
};
215223
} else if (currentRoute && currentRoute.pushedByRoute) {
216-
// Back navigation — record current URL for potential forward
217-
forwardStack.current.push(currentRoute.pathname + (currentRoute.search || ''));
224+
// Back navigation. Record current location key for potential forward
225+
forwardStack.current.push(currentLocationKeyRef.current);
218226
const prevInfo = locationHistory.current.findLastLocation(currentRoute);
219227
incomingRouteParams.current = { ...prevInfo, routeAction: 'pop', routeDirection: 'back' };
220228
} else {
@@ -346,6 +354,9 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr
346354
setRouteInfo(routeInfo);
347355
}
348356

357+
// Update the current location key after processing the history change.
358+
// This ensures the forward stack records the correct key when navigating back.
359+
currentLocationKeyRef.current = location.key;
349360
incomingRouteParams.current = null;
350361
};
351362

@@ -481,8 +492,8 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr
481492
const condition1 = routeInfo.lastPathname === routeInfo.pushedByRoute;
482493
const condition2 = prevInfo.pathname === routeInfo.pushedByRoute && routeInfo.tab === '' && prevInfo.tab === '';
483494
if (condition1 || condition2) {
484-
// Record current URL so browser forward is detectable
485-
forwardStack.current.push(routeInfo.pathname + (routeInfo.search || ''));
495+
// Record the current location key so browser forward is detectable
496+
forwardStack.current.push(currentLocationKeyRef.current);
486497
navigate(-1);
487498
} else {
488499
/**

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

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ interface StackManagerProps {
3333
}
3434

3535
const isViewVisible = (el: HTMLElement) =>
36-
!el.classList.contains('ion-page-invisible') && !el.classList.contains('ion-page-hidden');
36+
!el.classList.contains('ion-page-invisible') && !el.classList.contains('ion-page-hidden') && el.style.display !== 'none';
3737

3838
const hideIonPageElement = (element: HTMLElement | undefined): void => {
3939
if (element) {
@@ -44,6 +44,7 @@ const hideIonPageElement = (element: HTMLElement | undefined): void => {
4444

4545
const showIonPageElement = (element: HTMLElement | undefined): void => {
4646
if (element) {
47+
element.style.removeProperty('display');
4748
element.classList.remove('ion-page-hidden');
4849
element.removeAttribute('aria-hidden');
4950
}
@@ -322,8 +323,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
322323

323324
const enteringEl = enteringViewItem.ionPageElement;
324325
if (enteringEl) {
325-
enteringEl.classList.remove('ion-page-hidden', 'ion-page-invisible');
326-
enteringEl.removeAttribute('aria-hidden');
326+
showIonPageElement(enteringEl);
327+
enteringEl.classList.remove('ion-page-invisible');
327328
}
328329

329330
this.forceUpdate();
@@ -417,7 +418,9 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
417418

418419
this.lastTransition = currentTransition;
419420

420-
this.transitionPage(routeInfo, enteringViewItem, leavingViewItem);
421+
const shouldSkipAnimation = this.applySkipAnimationIfNeeded(enteringViewItem, leavingViewItem);
422+
423+
this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, undefined, false, shouldSkipAnimation);
421424

422425
if (shouldUnmountLeavingViewItem && leavingViewItem && enteringViewItem !== leavingViewItem) {
423426
leavingViewItem.mount = false;
@@ -547,6 +550,36 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
547550
}
548551
}
549552

553+
/**
554+
* Determines whether to skip the transition animation and, if so, immediately
555+
* hides the leaving view with inline `display:none`.
556+
*
557+
* Skips transitions in outlets nested inside a parent IonPage. These outlets
558+
* render pages inside a parent page's content area. The MD animation shows
559+
* both entering and leaving pages simultaneously, causing text overlap and
560+
* nested scrollbars (each page has its own IonContent). Top-level outlets
561+
* are unaffected and animate normally.
562+
*
563+
* Uses inline display:none rather than ion-page-hidden class because core's
564+
* beforeTransition() removes ion-page-hidden via setPageHidden().
565+
* Inline display:none survives that removal, keeping the page hidden
566+
* until React unmounts it after ionViewDidLeave fires.
567+
*/
568+
private applySkipAnimationIfNeeded(
569+
enteringViewItem: ViewItem,
570+
leavingViewItem: ViewItem | undefined
571+
): boolean {
572+
const isNestedOutlet = !!this.routerOutletElement?.closest('.ion-page');
573+
const shouldSkip = isNestedOutlet && !!leavingViewItem && enteringViewItem !== leavingViewItem;
574+
575+
if (shouldSkip && leavingViewItem?.ionPageElement) {
576+
leavingViewItem.ionPageElement.style.setProperty('display', 'none');
577+
leavingViewItem.ionPageElement.setAttribute('aria-hidden', 'true');
578+
}
579+
580+
return shouldSkip;
581+
}
582+
550583
/**
551584
* Handles entering view with no ion-page element yet (waiting for render).
552585
*/
@@ -609,7 +642,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
609642
const latestLeavingView = this.context.findLeavingViewItemByRouteInfo(routeInfo, this.id) ?? leavingViewItem;
610643

611644
if (latestEnteringView?.ionPageElement) {
612-
this.transitionPage(routeInfo, latestEnteringView, latestLeavingView ?? undefined);
645+
const shouldSkipAnimation = this.applySkipAnimationIfNeeded(latestEnteringView, latestLeavingView ?? undefined);
646+
this.transitionPage(routeInfo, latestEnteringView, latestLeavingView ?? undefined, undefined, false, shouldSkipAnimation);
613647

614648
if (shouldUnmountLeavingViewItem && latestLeavingView && latestEnteringView !== latestLeavingView) {
615649
latestLeavingView.mount = false;
@@ -863,6 +897,17 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
863897
return;
864898
}
865899

900+
/**
901+
* Don't let a nested element (e.g., ion-router-outlet with ionPage prop)
902+
* override an existing IonPage registration when the existing element is
903+
* an ancestor of the new one. This ensures ionPageElement always points
904+
* to the outermost IonPage, which is needed to properly hide the entire
905+
* page during back navigation (not just the inner outlet).
906+
*/
907+
if (oldPageElement && oldPageElement !== page && oldPageElement.isConnected && oldPageElement.contains(page)) {
908+
return;
909+
}
910+
866911
foundView.ionPageElement = page;
867912
foundView.ionRoute = true;
868913

@@ -1008,13 +1053,18 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
10081053
* @param progressAnimation Indicates if the transition is part of a
10091054
* gesture controlled animation (e.g., swipe to go back).
10101055
* Defaults to `false`.
1056+
* @param skipAnimation When true, forces `duration: 0` so the page
1057+
* swap is instant (no visible animation). Used for ionPage outlets
1058+
* and back navigations that unmount the leaving view to prevent
1059+
* overlapping content during the transition. Defaults to `false`.
10111060
*/
10121061
async transitionPage(
10131062
routeInfo: RouteInfo,
10141063
enteringViewItem: ViewItem,
10151064
leavingViewItem?: ViewItem,
10161065
direction?: 'forward' | 'back',
1017-
progressAnimation = false
1066+
progressAnimation = false,
1067+
skipAnimation = false
10181068
) {
10191069
const runCommit = async (enteringEl: HTMLElement, leavingEl?: HTMLElement) => {
10201070
const skipTransition = this.skipTransition;
@@ -1055,7 +1105,7 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
10551105
}
10561106

10571107
await routerOutlet.commit(enteringEl, leavingEl, {
1058-
duration: skipTransition || directionToUse === undefined ? 0 : undefined,
1108+
duration: skipTransition || skipAnimation || directionToUse === undefined ? 0 : undefined,
10591109
direction: directionToUse,
10601110
showGoBack: !!routeInfo.pushedByRoute,
10611111
progressAnimation,

0 commit comments

Comments
 (0)