Skip to content

bug: react-router, a /* route is overwritten by a more specific sibling route and its page is unmounted #31477

Description

@ilodezis

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

  1. Clone the reproduction, then npm install and npm run dev.
  2. Scroll the Feed tab down.
  3. Tap any "Open item" button.
  4. Inspect the outer ion-router-outlet: only the detail page is in it, and the original <ion-tabs> is no longer connected.
  5. 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.

  1. 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.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    type: buga confirmed bug report

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions