Skip to content

Commit 28f94d8

Browse files
committed
fix(react-router): distinguish browser forward from back in POP events
1 parent a9f94ec commit 28f94d8

3 files changed

Lines changed: 93 additions & 7 deletions

File tree

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

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr
8282
const currentTab = useRef<string | undefined>(undefined);
8383
const viewStack = useRef(new ReactRouterViewStack());
8484
const incomingRouteParams = useRef<Partial<RouteInfo> | null>(null);
85+
/**
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).
90+
*/
91+
const forwardStack = useRef<string[]>([]);
8592

8693
const [routeInfo, setRouteInfo] = useState<RouteInfo>({
8794
id: generateId('routeInfo'),
@@ -187,19 +194,31 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr
187194
}
188195
/**
189196
* A `POP` action can be triggered by the browser's back/forward
190-
* button.
197+
* button. Both fire as POP events, so we use a forward stack to
198+
* distinguish them: when going back, we push the leaving pathname
199+
* onto the stack. When the next POP's destination matches the top
200+
* of the stack, it's a forward navigation.
191201
*/
192202
if (action === 'POP') {
193203
const currentRoute = locationHistory.current.current();
194-
/**
195-
* Check if the current route was "pushed" by a previous route
196-
* (indicates a linear history path).
197-
*/
198-
if (currentRoute && currentRoute.pushedByRoute) {
204+
const isForwardNavigation =
205+
forwardStack.current.length > 0 &&
206+
forwardStack.current[forwardStack.current.length - 1] === location.pathname + location.search;
207+
208+
if (isForwardNavigation) {
209+
forwardStack.current.pop();
210+
incomingRouteParams.current = {
211+
routeAction: 'push',
212+
routeDirection: 'forward',
213+
tab: tabToUse,
214+
};
215+
} else if (currentRoute && currentRoute.pushedByRoute) {
216+
// Back navigation — record current URL for potential forward
217+
forwardStack.current.push(currentRoute.pathname + (currentRoute.search || ''));
199218
const prevInfo = locationHistory.current.findLastLocation(currentRoute);
200219
incomingRouteParams.current = { ...prevInfo, routeAction: 'pop', routeDirection: 'back' };
201-
// It's a non-linear history path like a direct link.
202220
} else {
221+
// It's a non-linear history path like a direct link.
203222
incomingRouteParams.current = {
204223
routeAction: 'pop',
205224
routeDirection: 'none',
@@ -218,6 +237,12 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr
218237
}
219238
}
220239

240+
// New navigation (PUSH) invalidates browser forward history,
241+
// so clear our forward stack to stay in sync.
242+
if (action === 'PUSH') {
243+
forwardStack.current = [];
244+
}
245+
221246
let routeInfo: RouteInfo;
222247

223248
// If we're navigating away from tabs to a non-tab route, clear the current tab
@@ -456,12 +481,17 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr
456481
const condition1 = routeInfo.lastPathname === routeInfo.pushedByRoute;
457482
const condition2 = prevInfo.pathname === routeInfo.pushedByRoute && routeInfo.tab === '' && prevInfo.tab === '';
458483
if (condition1 || condition2) {
484+
// Record current URL so browser forward is detectable
485+
forwardStack.current.push(routeInfo.pathname + (routeInfo.search || ''));
459486
navigate(-1);
460487
} else {
461488
/**
462489
* It's a non-linear back navigation.
463490
* e.g., direct link or tab switch or nested navigation with redirects
491+
* Clear forward stack since the REPLACE-based navigate resets history
492+
* position, making any prior forward entries unreachable.
464493
*/
494+
forwardStack.current = [];
465495
handleNavigate(prevInfo.pathname + (prevInfo.search || ''), 'pop', 'back', incomingAnimation);
466496
}
467497
/**

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,30 @@ describe('Nested Outlets 2', () => {
117117
cy.ionBackClick('list');
118118
cy.ionPageVisible('home');
119119
});
120+
121+
it('/nested-outlet2 > Browser back/forward across nested outlets should show correct page', () => {
122+
cy.visit(`http://localhost:${port}/nested-outlet2`);
123+
cy.ionPageVisible('home');
124+
125+
// Navigate: Home → Welcome → List → Item #1
126+
cy.ionNav('ion-item', 'Go to Welcome');
127+
cy.ionPageVisible('welcome');
128+
cy.ionNav('ion-item', 'Go to list from Welcome');
129+
cy.ionPageVisible('list');
130+
cy.ionNav('ion-item', 'Item #1');
131+
cy.ionPageVisible('item');
132+
cy.location('pathname').should('eq', '/nested-outlet2/list/1');
133+
134+
// Browser back: Item → List
135+
cy.go('back');
136+
cy.ionPageVisible('list');
137+
cy.location('pathname').should('eq', '/nested-outlet2/list');
138+
139+
// Browser forward: List → Item (this was showing Welcome page before the fix)
140+
cy.go('forward');
141+
cy.wait(500);
142+
cy.ionPageVisible('item');
143+
cy.location('pathname').should('eq', '/nested-outlet2/list/1');
144+
cy.ionPageDoesNotExist('welcome');
145+
});
120146
});

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,36 @@ describe('Routing Tests', () => {
204204
cy.ionPageVisible('home-page');
205205
});
206206

207+
it('/ > Details 1 > Details 2 > Details 3 > Browser Back > Browser Forward, should be back on Details 3', () => {
208+
// Tests browser forward button within a tab's own navigation stack
209+
cy.visit(`http://localhost:${port}/routing/`);
210+
cy.ionNav('ion-item', 'Details 1');
211+
cy.ionPageVisible('home-details-page-1');
212+
cy.ionNav('ion-button', 'Go to Details 2');
213+
cy.ionPageVisible('home-details-page-2');
214+
cy.ionNav('ion-button', 'Go to Details 3');
215+
cy.ionPageVisible('home-details-page-3');
216+
cy.go('back');
217+
cy.ionPageVisible('home-details-page-2');
218+
cy.go('forward');
219+
cy.wait(500);
220+
cy.ionPageVisible('home-details-page-3');
221+
});
222+
223+
it('/ > Details 1 > Settings Details 1 > Browser Back > Browser Forward, should show Settings Details 1', () => {
224+
// Tests browser forward button across tabs (cross-tab forward)
225+
cy.visit(`http://localhost:${port}/routing/`);
226+
cy.ionNav('ion-item', 'Details 1');
227+
cy.ionPageVisible('home-details-page-1');
228+
cy.ionNav('ion-button', 'Go to Settings Details 1');
229+
cy.ionPageVisible('settings-details-page-1');
230+
cy.go('back');
231+
cy.ionPageVisible('home-details-page-1');
232+
cy.go('forward');
233+
cy.wait(500);
234+
cy.ionPageVisible('settings-details-page-1');
235+
});
236+
207237
it('when props get passed into a route render, the component should update', () => {
208238
cy.visit(`http://localhost:${port}/routing/propstest`);
209239
cy.ionPageVisible('props-test');

0 commit comments

Comments
 (0)