Skip to content

Commit a6ee4c4

Browse files
committed
fix(react-router): cleaning up tab navigation to use proper navigation step post debugging
1 parent 4fb7fad commit a6ee4c4

3 files changed

Lines changed: 141 additions & 27 deletions

File tree

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

Lines changed: 49 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,18 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr
114114
// for future navigations once React has committed the mount. This avoids
115115
// duplicate entries when React StrictMode runs an extra render pre-commit.
116116
locationHistory.current.add(routeInfo);
117+
118+
// If IonTabBar already called handleSetCurrentTab during render (before this
119+
// effect), the tab was stored in currentTab.current but the history entry was
120+
// not yet seeded. Apply the pending tab to the seed entry now.
121+
if (currentTab.current) {
122+
const ri = { ...locationHistory.current.current() };
123+
if (ri.tab !== currentTab.current) {
124+
ri.tab = currentTab.current;
125+
locationHistory.current.update(ri);
126+
}
127+
}
128+
117129
registerHistoryListener(handleHistoryChange);
118130

119131
didMountRef.current = true;
@@ -179,14 +191,17 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr
179191
const leavingUrl = leavingLocationInfo.pathname + leavingLocationInfo.search;
180192
if (leavingUrl !== location.pathname + location.search) {
181193
if (!incomingRouteParams.current) {
182-
// Determine if the destination is a tab route by checking if it matches
183-
// the pattern of tab routes (containing /tabs/ in the path)
184-
const isTabRoute = /\/tabs(\/|$)/.test(location.pathname);
185-
const tabToUse = isTabRoute ? currentTab.current : undefined;
186-
187-
// If we're leaving tabs entirely, clear the current tab
188-
if (!isTabRoute && currentTab.current) {
189-
currentTab.current = undefined;
194+
// Use history-based tab detection instead of URL-pattern heuristics,
195+
// so tab routes work with any URL structure (not just paths containing "/tabs").
196+
// Fall back to currentTab.current only when the destination is within the
197+
// current tab's path hierarchy (prevents non-tab routes from inheriting a tab).
198+
let tabToUse = locationHistory.current.findTabForPathname(location.pathname);
199+
if (!tabToUse && currentTab.current) {
200+
const tabFirstRoute = locationHistory.current.getFirstRouteInfoForTab(currentTab.current);
201+
const tabRootPath = tabFirstRoute?.pathname;
202+
if (tabRootPath && (location.pathname === tabRootPath || location.pathname.startsWith(tabRootPath + '/'))) {
203+
tabToUse = currentTab.current;
204+
}
190205
}
191206

192207
/**
@@ -256,7 +271,7 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr
256271
let routeInfo: RouteInfo;
257272

258273
// If we're navigating away from tabs to a non-tab route, clear the current tab
259-
if (!/\/tabs(\/|$)/.test(location.pathname) && currentTab.current) {
274+
if (!locationHistory.current.findTabForPathname(location.pathname) && currentTab.current) {
260275
currentTab.current = undefined;
261276
}
262277

@@ -441,7 +456,13 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr
441456
*/
442457
const handleSetCurrentTab = (tab: string) => {
443458
currentTab.current = tab;
444-
const ri = { ...locationHistory.current.current() };
459+
const current = locationHistory.current.current();
460+
if (!current) {
461+
// locationHistory not yet seeded (e.g., called during initial render
462+
// before mount effect). The mount effect will seed the correct entry.
463+
return;
464+
}
465+
const ri = { ...current };
445466
if (ri.tab !== tab) {
446467
ri.tab = tab;
447468
locationHistory.current.update(ri);
@@ -553,26 +574,27 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr
553574
let navigationTab = tab;
554575

555576
// If no explicit tab is provided and we're in a tab context,
556-
// check if the destination path is outside of the current tab context
577+
// check if the destination path is outside of the current tab context.
578+
// Uses history-based tab detection instead of URL pattern matching,
579+
// so it works with any tab URL structure.
557580
if (!tab && currentTab.current && path) {
558-
// Get the current route info to understand where we are
559-
const currentRoute = locationHistory.current.current();
560-
561-
// If we're navigating from a tab route to a completely different path structure,
562-
// we should clear the tab context. This is a simplified check that assumes
563-
// tab routes share a common parent path.
564-
if (currentRoute && currentRoute.pathname) {
565-
// Extract the base tab path (e.g., /routing/tabs from /routing/tabs/home)
566-
const tabBaseMatch = currentRoute.pathname.match(/^(.*\/tabs)/);
567-
if (tabBaseMatch) {
568-
const tabBasePath = tabBaseMatch[1];
569-
// If the new path doesn't start with the tab base path, we're leaving tabs
570-
if (!path.startsWith(tabBasePath)) {
581+
// Check if destination was previously visited in a tab context
582+
const destinationTab = locationHistory.current.findTabForPathname(path);
583+
if (destinationTab) {
584+
// Previously visited as a tab route - use the known tab
585+
navigationTab = destinationTab;
586+
} else {
587+
// New destination - check if it's a child of the current tab's root path
588+
const tabFirstRoute = locationHistory.current.getFirstRouteInfoForTab(currentTab.current);
589+
if (tabFirstRoute) {
590+
const tabRootPath = tabFirstRoute.pathname;
591+
if (path === tabRootPath || path.startsWith(tabRootPath + '/')) {
592+
// Still within the current tab's path hierarchy
593+
navigationTab = currentTab.current;
594+
} else {
595+
// Destination is outside the current tab context
571596
currentTab.current = undefined;
572597
navigationTab = undefined;
573-
} else {
574-
// Still within tabs, preserve the tab context
575-
navigationTab = currentTab.current;
576598
}
577599
}
578600
}

packages/react-router/test/base/tests/e2e/specs/tab-history-isolation.cy.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,84 @@ describe('Tab History Isolation', () => {
121121
cy.ionPageVisible('tab-a');
122122
cy.url().should('include', '/tab-history-isolation/a');
123123
});
124+
125+
/**
126+
* Browser back/forward tests for non-"/tabs/" URL paths.
127+
*
128+
* These tests verify that per-tab history isolation works correctly
129+
* when using browser back/forward buttons (POP events) with tab routes
130+
* that do NOT contain "/tabs/" in their URL path.
131+
*
132+
* The tab-history-isolation routes use paths like /tab-history-isolation/a,
133+
* /tab-history-isolation/b, etc. — no "/tabs/" segment. This exercises
134+
* the context-driven tab detection (via location history) rather than
135+
* URL-pattern-based detection.
136+
*/
137+
it('should preserve tab context through browser back from detail page within a tab', () => {
138+
cy.visit(`http://localhost:${port}/tab-history-isolation/a`);
139+
cy.ionPageVisible('tab-a');
140+
141+
// Navigate to details within Tab A
142+
cy.get('#go-to-a-details').click();
143+
cy.ionPageHidden('tab-a');
144+
cy.ionPageVisible('tab-a-details');
145+
146+
// Use browser back - should go back within the same tab
147+
cy.go('back');
148+
cy.ionPageVisible('tab-a');
149+
cy.url().should('include', '/tab-history-isolation/a');
150+
cy.url().should('not.include', '/details');
151+
});
152+
153+
it('should handle browser forward after browser back within a tab', () => {
154+
cy.visit(`http://localhost:${port}/tab-history-isolation/a`);
155+
cy.ionPageVisible('tab-a');
156+
157+
// Navigate to details within Tab A
158+
cy.get('#go-to-a-details').click();
159+
cy.ionPageHidden('tab-a');
160+
cy.ionPageVisible('tab-a-details');
161+
162+
// Browser back
163+
cy.go('back');
164+
cy.ionPageVisible('tab-a');
165+
cy.url().should('not.include', '/details');
166+
167+
// Browser forward - should return to details
168+
cy.go('forward');
169+
cy.ionPageVisible('tab-a-details');
170+
cy.url().should('include', '/tab-history-isolation/a/details');
171+
});
172+
173+
it('should preserve per-tab history when using browser back after navigating within a tab and switching tabs', () => {
174+
cy.visit(`http://localhost:${port}/tab-history-isolation/a`);
175+
cy.ionPageVisible('tab-a');
176+
177+
// Navigate to details within Tab A
178+
cy.get('#go-to-a-details').click();
179+
cy.ionPageHidden('tab-a');
180+
cy.ionPageVisible('tab-a-details');
181+
182+
// Switch to Tab B via tab bar
183+
cy.ionTabClick('Tab B');
184+
cy.ionPageHidden('tab-a-details');
185+
cy.ionPageVisible('tab-b');
186+
187+
// Navigate to details within Tab B
188+
cy.get('#go-to-b-details').click();
189+
cy.ionPageHidden('tab-b');
190+
cy.ionPageVisible('tab-b-details');
191+
192+
// Use browser back from Tab B details - should go back to Tab B root
193+
cy.go('back');
194+
cy.ionPageVisible('tab-b');
195+
cy.url().should('include', '/tab-history-isolation/b');
196+
cy.url().should('not.include', '/details');
197+
198+
// Switch back to Tab A - should still show Tab A details (preserved)
199+
cy.ionTabClick('Tab A');
200+
cy.ionPageHidden('tab-b');
201+
cy.ionPageVisible('tab-a-details');
202+
cy.url().should('include', '/tab-history-isolation/a/details');
203+
});
124204
});

packages/react/src/routing/LocationHistory.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,4 +175,16 @@ export class LocationHistory {
175175
canGoBack() {
176176
return this.locationHistory.length > 1;
177177
}
178+
179+
findTabForPathname(pathname: string): string | undefined {
180+
for (const tab of Object.keys(this.tabHistory)) {
181+
const routeInfos = this.tabHistory[tab];
182+
for (let i = routeInfos.length - 1; i >= 0; i--) {
183+
if (routeInfos[i].pathname === pathname) {
184+
return tab;
185+
}
186+
}
187+
}
188+
return undefined;
189+
}
178190
}

0 commit comments

Comments
 (0)