Prerequisites
Ionic Framework Version
v9.x (@ionic/react and @ionic/react-router 9.0.4; the 9.0.5 nightly has the same code)
Current Behavior
An outer IonRouterOutlet holds a tabs page on a splat route and a detail page on a more specific sibling route, so the detail page is pushed over the whole tab bar:
<IonRouterOutlet>
<Route path="/feed/:id" element={<Detail />} />
<Route path="/*" element={<Tabs />} /> {/* tabs at /feed, /profile */}
</IonRouterOutlet>
Pushing /feed/12 from the Feed tab with router.push('/feed/12', 'forward') does not add a view for /feed/:id. findViewItemByPath matches the existing /* view item, since a splat matches every pathname and its pathnameBase stays the same. The StackManager then sets enteringViewItem.reactElement = enteringRoute, which replaces the tabs page's element with <Detail />. The tabs page is unmounted and its DOM is removed. No transition runs and nothing is left behind the detail page.
On back, a new tabs page is created. Any state inside it is lost, including the Feed's scroll position. The swipe-to-go-back gesture has nothing to reveal.
With path="*" the tabs are unmounted too, by a different path: the catch-all deactivation in renderViewItem treats a bare * as a not-found route once a more specific sibling matches.
Expected Behavior
Pushing /feed/12 creates a view item for /feed/:id. The tabs page stays mounted and hidden behind it. Back reveals the same tabs page with its state and scroll intact, the same way it works when the tabs sit under a prefix such as /tabs/*.
Steps to Reproduce
- Clone the reproduction, then
npm install and npm run dev.
- Scroll the Feed tab down.
- Tap any "Open item" button.
- Inspect the outer
ion-router-outlet: only the detail page is in it, and the original <ion-tabs> is no longer connected.
- Tap back: a new tabs page appears with the Feed at
scrollTop 0.
Code Reproduction URL
https://gist.github.com/ilodezis/a1cb9ddc036da056bf6fe36b241ccf9f
Ionic Info
No Ionic CLI in this project. Relevant versions:
@ionic/react 9.0.4
@ionic/react-router 9.0.4
react / react-dom 19.3.0
react-router-dom 6.30.6
vite 8.3.0
Additional Information
This layout comes from moving an app off React Router 5. There the tabs route was path={['/feed', '/profile']}, one route and one view item. Under React Router 6 a splat is the only way to keep a single view item for tabs that live at the root. Separate /feed and /profile routes each mount their own IonTabs, and the Feed is thrown away on every tab switch.
Two changes fix it for us: the tabs stay mounted under the detail page, and back animates and restores the same Feed.
- In
findViewItemByPath's matchView, do not reuse a splat-only view (* or /*) for a pathname that a more specific sibling route of the same outlet owns. findRouteByRouteInfo already ranks the outlet's routes, so the outlet's children only need to be remembered in getChildrenToRender.
- In
handleReadyEnteringView, the "navigating within the same wildcard container" shortcut treats /* as containing every pathname, because containerBase is ''. So back from /feed/12 to /feed skipped the transition entirely. Take the shortcut only when there is no leaving view item. Entering and leaving on the same view is already handled just above it.
getChildrenToRender = (outletId, ionRouterOutlet, routeInfo, reRender, parentPathnameBase) => {
const viewItems = this.getViewItemsForOutlet(outletId);
+ (this.outletRoutes ??= new Map()).set(outletId, ionRouterOutlet.props.children);
@@
const storedParentPaths = this.outletParentPaths;
+ const outletRoutes = this.outletRoutes;
@@
if ((viewItemPath === '*' || viewItemPath === '/*') && !v.mount)
return false;
+ // A splat view matches every pathname. Do not hand it a pathname that a
+ // more specific sibling route owns: that route has no view item yet, and
+ // reusing the splat's would overwrite its element with the sibling's.
+ if ((viewItemPath === '*' || viewItemPath === '/*') && outletRoutes?.has(v.outletId)) {
+ const best = findRouteByRouteInfo(outletRoutes.get(v.outletId), { pathname }, storedParentPaths.get(v.outletId));
+ if (best && best.props.path !== viewItemPath)
+ return false;
+ }
@@
- if (isWildcardContainerRoute && routeInfo.lastPathname) {
+ if (isWildcardContainerRoute && routeInfo.lastPathname && !leavingViewItem) {
We run this as a patch-package patch against dist/index.js. With it, the reproduction keeps the same <ion-tabs> under the detail page, and back returns to it at the same scroll. Happy to open a PR against the source if this direction looks right.
Prerequisites
Ionic Framework Version
v9.x (
@ionic/reactand@ionic/react-router9.0.4; the 9.0.5 nightly has the same code)Current Behavior
An outer
IonRouterOutletholds a tabs page on a splat route and a detail page on a more specific sibling route, so the detail page is pushed over the whole tab bar:Pushing
/feed/12from the Feed tab withrouter.push('/feed/12', 'forward')does not add a view for/feed/:id.findViewItemByPathmatches the existing/*view item, since a splat matches every pathname and itspathnameBasestays the same. The StackManager then setsenteringViewItem.reactElement = enteringRoute, which replaces the tabs page's element with<Detail />. The tabs page is unmounted and its DOM is removed. No transition runs and nothing is left behind the detail page.On back, a new tabs page is created. Any state inside it is lost, including the Feed's scroll position. The swipe-to-go-back gesture has nothing to reveal.
With
path="*"the tabs are unmounted too, by a different path: the catch-all deactivation inrenderViewItemtreats a bare*as a not-found route once a more specific sibling matches.Expected Behavior
Pushing
/feed/12creates a view item for/feed/:id. The tabs page stays mounted and hidden behind it. Back reveals the same tabs page with its state and scroll intact, the same way it works when the tabs sit under a prefix such as/tabs/*.Steps to Reproduce
npm installandnpm run dev.ion-router-outlet: only the detail page is in it, and the original<ion-tabs>is no longer connected.scrollTop0.Code Reproduction URL
https://gist.github.com/ilodezis/a1cb9ddc036da056bf6fe36b241ccf9f
Ionic Info
No Ionic CLI in this project. Relevant versions:
Additional Information
This layout comes from moving an app off React Router 5. There the tabs route was
path={['/feed', '/profile']}, one route and one view item. Under React Router 6 a splat is the only way to keep a single view item for tabs that live at the root. Separate/feedand/profileroutes each mount their ownIonTabs, and the Feed is thrown away on every tab switch.Two changes fix it for us: the tabs stay mounted under the detail page, and back animates and restores the same Feed.
findViewItemByPath'smatchView, do not reuse a splat-only view (*or/*) for a pathname that a more specific sibling route of the same outlet owns.findRouteByRouteInfoalready ranks the outlet's routes, so the outlet's children only need to be remembered ingetChildrenToRender.handleReadyEnteringView, the "navigating within the same wildcard container" shortcut treats/*as containing every pathname, becausecontainerBaseis''. So back from/feed/12to/feedskipped the transition entirely. Take the shortcut only when there is no leaving view item. Entering and leaving on the same view is already handled just above it.getChildrenToRender = (outletId, ionRouterOutlet, routeInfo, reRender, parentPathnameBase) => { const viewItems = this.getViewItemsForOutlet(outletId); + (this.outletRoutes ??= new Map()).set(outletId, ionRouterOutlet.props.children); @@ const storedParentPaths = this.outletParentPaths; + const outletRoutes = this.outletRoutes; @@ if ((viewItemPath === '*' || viewItemPath === '/*') && !v.mount) return false; + // A splat view matches every pathname. Do not hand it a pathname that a + // more specific sibling route owns: that route has no view item yet, and + // reusing the splat's would overwrite its element with the sibling's. + if ((viewItemPath === '*' || viewItemPath === '/*') && outletRoutes?.has(v.outletId)) { + const best = findRouteByRouteInfo(outletRoutes.get(v.outletId), { pathname }, storedParentPaths.get(v.outletId)); + if (best && best.props.path !== viewItemPath) + return false; + } @@ - if (isWildcardContainerRoute && routeInfo.lastPathname) { + if (isWildcardContainerRoute && routeInfo.lastPathname && !leavingViewItem) {We run this as a patch-package patch against
dist/index.js. With it, the reproduction keeps the same<ion-tabs>under the detail page, and back returns to it at the same scroll. Happy to open a PR against the source if this direction looks right.