diff --git a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx index ab0bb96263f..fb78cce7857 100644 --- a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx +++ b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx @@ -15,7 +15,7 @@ import { analyzeRouteChildren, computeParentPath } from './utils/computeParentPa import { derivePathnameToMatch, matchPath } from './utils/pathMatching'; import { normalizePathnameForComparison } from './utils/pathNormalization'; import { extractRouteChildren, isNavigateElement } from './utils/routeElements'; -import { sortViewsBySpecificity } from './utils/viewItemUtils'; +import { isForwardPush, isSwipeRevealed, sortViewsBySpecificity } from './utils/viewItemUtils'; /** * Delay in milliseconds before removing a Navigate view item after a redirect. @@ -454,7 +454,7 @@ export class ReactRouterViewStack extends ViewStacks { // Deactivate wildcard (catch-all) and empty-path (default) routes when a more-specific route matches. // This prevents "Not found" or fallback pages from showing alongside valid routes. - if (routePath === '*' || routePath === '') { + if (routePath === '*' || routePath === '/*' || routePath === '') { // Check if any other view in this outlet has a match for the current route const outletViews = this.getViewItemsForOutlet(viewItem.outletId); @@ -510,8 +510,15 @@ export class ReactRouterViewStack extends ViewStacks { } } - if (hasSpecificMatch) { - viewItem.mount = false; + if (hasSpecificMatch && !isSwipeRevealed(viewItem)) { + // A splat can be the outlet's container page rather than a "not found" fallback. + // Pushed over it is the page underneath, so unmounting it destroys its state and + // leaves nothing for back to reveal. Hiding it below covers that. A view with no + // ion-page was never a page in the stack and has nothing to hide, so it still + // unmounts. + if (!isForwardPush(routeInfo) || !viewItem.ionPageElement) { + viewItem.mount = false; + } if (viewItem.ionPageElement) { viewItem.ionPageElement.classList.add('ion-page-hidden'); viewItem.ionPageElement.setAttribute('aria-hidden', 'true'); @@ -629,8 +636,11 @@ export class ReactRouterViewStack extends ViewStacks { // Persist the mount path for subsequent calls, mirroring StackManager.outletMountPath. // Unlike outletParentPaths (cleared when parentPath is undefined), the mount path is - // intentionally sticky — it anchors the outlet's scope and is only removed in clear(). - if (result.outletMountPath && !this.outletMountPaths.has(outletId)) { + // intentionally sticky, it anchors the outlet's scope and is only removed in clear(). + // A root outlet is skipped because it is mounted under nothing, so an inferred path + // would scope it to whatever route was active. parentPathnameBase is undefined + // exactly when the outlet has no parent matches. + if (parentPathnameBase && result.outletMountPath && !this.outletMountPaths.has(outletId)) { this.outletMountPaths.set(outletId, result.outletMountPath); } } diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index 69ca358dc84..7fa9173eb29 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -20,6 +20,7 @@ import { import { derivePathnameToMatch, matchPath } from './utils/pathMatching'; import { stripTrailingSlash } from './utils/pathNormalization'; import { extractRouteChildren, getRoutesChildren, isNavigateElement } from './utils/routeElements'; +import { clearSwipeRevealed, isForwardPush, isOverMatchingRoute, markSwipeRevealed } from './utils/viewItemUtils'; /** * Delay in milliseconds before unmounting a view after a transition completes. @@ -118,6 +119,8 @@ export class StackManager extends React.PureComponent { routerOutletElement: HTMLIonRouterOutletElement | undefined; prevProps?: StackManagerProps; skipTransition: boolean; + /** The view item the in-flight swipe gesture revealed, so the same one gets cleared. */ + private swipeRevealedViewItem?: ViewItem; stackContextValue: StackContextState = { registerIonPage: this.registerIonPage.bind(this), @@ -219,7 +222,9 @@ export class StackManager extends React.PureComponent { hasWildcardRoute, }); - if (result.outletMountPath && !this.outletMountPath) { + // A root outlet is mounted under nothing, so caching the inferred path would scope + // it to whatever route was active and make every other pathname look out of scope. + if (!this.isRootOutlet && result.outletMountPath && !this.outletMountPath) { this.outletMountPath = result.outletMountPath; } @@ -297,14 +302,19 @@ export class StackManager extends React.PureComponent { } // For non-replace actions, only unmount for back navigation - const isForwardPush = routeInfo.routeAction === 'push' && (routeInfo as any).routeDirection === 'forward'; - if (!isForwardPush && routeInfo.routeDirection !== 'none' && enteringViewItem !== leavingViewItem) { + if (!isForwardPush(routeInfo) && routeInfo.routeDirection !== 'none' && enteringViewItem !== leavingViewItem) { return true; } return false; } + /** Clears the reveal flag from the view item the gesture marked. */ + private clearSwipeRevealedView(): void { + clearSwipeRevealed(this.swipeRevealedViewItem); + this.swipeRevealedViewItem = undefined; + } + /** * Handles out-of-scope outlet. Returns true if transition should be aborted. */ @@ -524,7 +534,9 @@ export class StackManager extends React.PureComponent { const previousInContainer = routeInfo.lastPathname.startsWith(containerBase + '/') || routeInfo.lastPathname === containerBase; - if (currentInContainer && previousInContainer) { + // A root-level "/*" leaves an empty base, so both checks above are true for every + // pathname and the shortcut would otherwise skip every navigation in this outlet. + if (containerBase !== '' && currentInContainer && previousInContainer) { const updatedMatch = matchComponent( enteringViewItem.reactElement, routeInfo.pathname, @@ -1085,6 +1097,7 @@ export class StackManager extends React.PureComponent { } this.waitingForIonPage = false; this.preservedViewItems.clear(); + this.clearSwipeRevealedView(); // Hide all views in this outlet before clearing. // This is critical for nested outlets - when the parent component unmounts, @@ -1120,15 +1133,41 @@ export class StackManager extends React.PureComponent { return; } + // A completed swipe keeps its reveal flag past goBack() so the deactivation scan does + // not re-hide the page between the gesture ending and the URL settling. Clear it at + // the start of the transition for the new pathname. + this.clearSwipeRevealedView(); + // Find entering and leaving view items const viewItems = this.findViewItems(routeInfo); let enteringViewItem = viewItems.enteringViewItem; let leavingViewItem = viewItems.leavingViewItem; - let shouldUnmountLeavingViewItem = this.shouldUnmountLeavingView(routeInfo, enteringViewItem, leavingViewItem); // Get parent path for nested outlets const parentPath = this.getParentPath(); + // Find the matching route element. This is React Router's own ranking of the outlet's + // routes, so it decides which route owns the pathname. + const enteringRoute = findRouteByRouteInfo(this.ionRouterOutlet?.props.children, routeInfo, parentPath) as + | React.ReactElement + | undefined; + + // The lookup above only matches view items that already exist, so a sibling route with + // no view item yet never wins there and an over-matching route (splat, index, empty + // path) comes back for pathnames it does not own. Drop it rather than reusing it, + // because overwriting its reactElement below would swap its page for the winning + // route's and unmount it. This has to resolve before shouldUnmountLeavingView and + // handleRootNavigation, which both branch on the entering view item. + const matchedRouteProps = enteringViewItem?.routeData?.childProps; + const matchedAnOverMatchingRoute = isOverMatchingRoute(matchedRouteProps ?? {}); + const routeOwnerDisagrees = enteringRoute !== undefined && enteringRoute.props.path !== matchedRouteProps?.path; + + if (enteringViewItem && matchedAnOverMatchingRoute && routeOwnerDisagrees) { + enteringViewItem = undefined; + } + + let shouldUnmountLeavingViewItem = this.shouldUnmountLeavingView(routeInfo, enteringViewItem, leavingViewItem); + // Handle out-of-scope outlet (route outside mount path) if (this.handleOutOfScopeOutlet(routeInfo)) { return; @@ -1152,13 +1191,6 @@ export class StackManager extends React.PureComponent { return; } - // Find the matching route element - const enteringRoute = findRouteByRouteInfo( - this.ionRouterOutlet?.props.children, - routeInfo, - parentPath - ) as React.ReactElement; - // Handle nested outlet with no matching route if (this.handleNoMatchingRoute(enteringRoute, enteringViewItem, leavingViewItem)) { return; @@ -1384,22 +1416,22 @@ export class StackManager extends React.PureComponent { const { routeInfo } = this.props; const swipeBackRouteInfo = this.getSwipeBackRouteInfo(); const enteringViewItem = this.findEnteringViewForSwipe(swipeBackRouteInfo); + const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false); // View might have mount=false but ionPageElement still in DOM const ionPageInDocument = Boolean( enteringViewItem?.ionPageElement && document.body.contains(enteringViewItem.ionPageElement) ); - // For wildcard/parameterized routes, the pattern path (e.g. "/foo/*") will - // never equal the resolved pathname (e.g. "/foo/bar"), so the pattern check - // alone isn't sufficient. Also, verify the entering view's resolved pathname - // differs from the current pathname — if they match, the entering and leaving - // views are the same and the swipe gesture shouldn't start. + // A splat consumes whatever is left of the pathname, so its match resolves to the whole + // current pathname and comparing pathnames can't tell a container page underneath a + // pushed sibling from the page being left. Compare the view items instead, like onEnd + // does. Without a leaving view onStart skips the transition, so reject that here too. const canStartSwipe = !!enteringViewItem && + !!leavingViewItem && (enteringViewItem.mount || ionPageInDocument) && - enteringViewItem.routeData.match.pattern.path !== routeInfo.pathname && - enteringViewItem.routeData.match.pathname !== routeInfo.pathname; + enteringViewItem !== leavingViewItem; debug('SwipeBackCanStart', () => ({ outletId: this.id, @@ -1409,6 +1441,7 @@ export class StackManager extends React.PureComponent { enteringViewPath: enteringViewItem?.reactElement?.props?.path, enteringMount: enteringViewItem?.mount, ionPageInDocument, + leavingViewId: leavingViewItem?.id, canStartSwipe, })); @@ -1416,6 +1449,10 @@ export class StackManager extends React.PureComponent { }; const onStart = async () => { + // Core only calls onEnd when a progress animation exists, so with animations off a + // previous gesture can leave its mark behind. Drop any stale one before marking. + this.clearSwipeRevealedView(); + const { routeInfo } = this.props; const swipeBackRouteInfo = this.getSwipeBackRouteInfo(); const enteringViewItem = this.findEnteringViewForSwipe(swipeBackRouteInfo); @@ -1446,6 +1483,12 @@ export class StackManager extends React.PureComponent { // When the gesture starts, kick off a transition controlled via swipe gesture if (enteringViewItem && leavingViewItem) { + // Without a leaving view there is no progress animation, and core only calls + // swipeHandler.onEnd when one exists, so nothing would clear the mark until the + // next transition. + markSwipeRevealed(enteringViewItem); + this.swipeRevealedViewItem = enteringViewItem; + await this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back', true); } @@ -1473,6 +1516,8 @@ export class StackManager extends React.PureComponent { const enteringViewItem = this.findEnteringViewForSwipe(swipeBackRouteInfo); const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false); + this.clearSwipeRevealedView(); + // Don't hide if entering and leaving are the same (parameterized route edge case) if (enteringViewItem !== leavingViewItem && enteringViewItem?.ionPageElement !== undefined) { hideIonPageElement(enteringViewItem.ionPageElement); diff --git a/packages/react-router/src/ReactRouter/utils/viewItemUtils.ts b/packages/react-router/src/ReactRouter/utils/viewItemUtils.ts index 4fc4bd07816..84c140e211a 100644 --- a/packages/react-router/src/ReactRouter/utils/viewItemUtils.ts +++ b/packages/react-router/src/ReactRouter/utils/viewItemUtils.ts @@ -1,4 +1,4 @@ -import type { ViewItem } from '@ionic/react'; +import type { RouteInfo, ViewItem } from '@ionic/react'; /** * Compares two routes by specificity for sorting (most specific first). @@ -37,6 +37,48 @@ export const compareRouteSpecificity = ( return 0; }; +/** + * True when a route matches more pathnames than its own path, so a splat, an index route, + * or a route with an empty or absent path. A lookup can return one of these for a pathname + * a more specific sibling owns, so callers must confirm ownership against React Router's + * ranking before reusing the view item. + * + * This is deliberately wider than the catch-all checks in ReactRouterViewStack, which each + * gate on a narrower shape for a different reason. Don't unify them with this helper. + */ +export const isOverMatchingRoute = (route: { path?: string; index?: boolean }): boolean => { + const { path, index } = route; + return !path || path.includes('*') || !!index; +}; + +/** True when the navigation pushed a new page forward on top of the current one. */ +export const isForwardPush = (routeInfo: Pick): boolean => + routeInfo.routeAction === 'push' && routeInfo.routeDirection === 'forward'; + +const swipeRevealed = new WeakSet(); + +/** + * Marks the page a swipe-back gesture has revealed. For the length of the drag that page + * is on screen while a more specific sibling still matches the current pathname, and the + * deactivation scan in `renderViewItem` would otherwise re-hide it on the next render and + * leave the user dragging a blank page. + */ +export const markSwipeRevealed = (viewItem: ViewItem | undefined): void => { + if (viewItem) { + swipeRevealed.add(viewItem); + } +}; + +/** Drops the mark once the gesture ends, so the view is hidden normally again. */ +export const clearSwipeRevealed = (viewItem: ViewItem | undefined): void => { + if (viewItem) { + swipeRevealed.delete(viewItem); + } +}; + +/** True while a swipe-back gesture is showing this view. */ +export const isSwipeRevealed = (viewItem: ViewItem): boolean => swipeRevealed.has(viewItem); + /** * Sorts view items by route specificity (most specific first). * diff --git a/packages/react-router/test/base/src/App.tsx b/packages/react-router/test/base/src/App.tsx index 1e26697edeb..96cb242cedc 100644 --- a/packages/react-router/test/base/src/App.tsx +++ b/packages/react-router/test/base/src/App.tsx @@ -1,25 +1,9 @@ -import { IonApp, setupIonicReact, LogLevel, IonRouterOutlet } from '@ionic/react'; +import { IonApp, IonRouterOutlet } from '@ionic/react'; import React from 'react'; import { Route, Navigate } from 'react-router-dom'; -/* Core CSS required for Ionic components to work properly */ -import '@ionic/react/css/core.css'; - -/* Basic CSS for apps built with Ionic */ -import '@ionic/react/css/normalize.css'; -import '@ionic/react/css/structure.css'; -import '@ionic/react/css/typography.css'; - -/* Optional CSS utils that can be commented out */ -import '@ionic/react/css/display.css'; -import '@ionic/react/css/flex-utils.css'; -import '@ionic/react/css/float-elements.css'; -import '@ionic/react/css/padding.css'; -import '@ionic/react/css/text-alignment.css'; -import '@ionic/react/css/text-transformation.css'; - -/* Theme variables */ -import './theme/variables.css'; +/* Ionic CSS and setupIonicReact */ +import './ionic-setup'; import Main from './pages/Main'; import { IonReactRouter } from '@ionic/react-router'; @@ -66,14 +50,16 @@ import { Step1, Step2, Step3, Step4 } from './pages/replace-params/ReplaceParams import { ParamSwipeBack, ParamSwipeBackB } from './pages/param-swipe-back/ParamSwipeBack'; import TabLifecycle from './pages/tab-lifecycle/TabLifecycle'; import TabLifecycleOutside from './pages/tab-lifecycle/TabLifecycleOutside'; -import { RouterLinkModifierClick, RouterLinkModifierClickTarget } from './pages/router-link-modifier-click/RouterLinkModifierClick'; +import { + RouterLinkModifierClick, + RouterLinkModifierClickTarget, +} from './pages/router-link-modifier-click/RouterLinkModifierClick'; import { NavigateRootPageA, NavigateRootPageB, NavigateRootPageC } from './pages/navigate-root/NavigateRoot'; import SuspenseOutlet from './pages/suspense-outlet/SuspenseOutlet'; import { PropsUpdateDirect, PropsUpdateRoutesWrapper } from './pages/props-update/PropsUpdate'; import DisabledButton from './pages/disabled-button/DisabledButton'; - -// Debug logs on so failing specs include the navigation diagnostics. -setupIonicReact({ logLevel: LogLevel.DEBUG }); +import SplatSibling from './pages/splat-sibling/SplatSibling'; +import { EmptyPathSibling, IndexSibling } from './pages/index-sibling/IndexSibling'; const App: React.FC = () => { return ( @@ -142,6 +128,9 @@ const App: React.FC = () => { } /> } /> } /> + } /> + } /> + } /> diff --git a/packages/react-router/test/base/src/index.tsx b/packages/react-router/test/base/src/index.tsx index de6c73b77f4..3c49e97c7a9 100644 --- a/packages/react-router/test/base/src/index.tsx +++ b/packages/react-router/test/base/src/index.tsx @@ -2,11 +2,17 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; +import RootSplatSiblingApp from './root-splat-sibling/RootSplatSiblingApp'; +import { ROOT_SPLAT_SIBLING_BASENAME } from './root-splat-sibling/basename'; + +/** + * A root-level splat route swallows every pathname in its outlet, so it can't share App's + * route tree. It gets its own root, picked here by pathname before anything renders. + */ +const { pathname } = window.location; +const isRootSplatSibling = + pathname === ROOT_SPLAT_SIBLING_BASENAME || pathname.startsWith(`${ROOT_SPLAT_SIBLING_BASENAME}/`); const container = document.getElementById('root'); const root = createRoot(container!); -root.render( - - - -); +root.render({isRootSplatSibling ? : }); diff --git a/packages/react-router/test/base/src/ionic-setup.ts b/packages/react-router/test/base/src/ionic-setup.ts new file mode 100644 index 00000000000..bd9f4918b32 --- /dev/null +++ b/packages/react-router/test/base/src/ionic-setup.ts @@ -0,0 +1,28 @@ +/** + * Ionic CSS and runtime config. Both App and RootSplatSiblingApp import this because + * App.test.tsx renders App with no index.tsx in the graph. + * + * Debug logging is on so a failing spec includes the navigation diagnostics. + */ +import { setupIonicReact, LogLevel } from '@ionic/react'; + +/* Core CSS required for Ionic components to work properly */ +import '@ionic/react/css/core.css'; + +/* Basic CSS for apps built with Ionic */ +import '@ionic/react/css/normalize.css'; +import '@ionic/react/css/structure.css'; +import '@ionic/react/css/typography.css'; + +/* Optional CSS utils that can be commented out */ +import '@ionic/react/css/display.css'; +import '@ionic/react/css/flex-utils.css'; +import '@ionic/react/css/float-elements.css'; +import '@ionic/react/css/padding.css'; +import '@ionic/react/css/text-alignment.css'; +import '@ionic/react/css/text-transformation.css'; + +/* Theme variables */ +import './theme/variables.css'; + +setupIonicReact({ logLevel: LogLevel.DEBUG }); diff --git a/packages/react-router/test/base/src/pages/Main.tsx b/packages/react-router/test/base/src/pages/Main.tsx index 46866f954bd..3dc6709f531 100644 --- a/packages/react-router/test/base/src/pages/Main.tsx +++ b/packages/react-router/test/base/src/pages/Main.tsx @@ -10,6 +10,7 @@ import { IonLabel, } from '@ionic/react'; import React from 'react'; +import { ROOT_SPLAT_SIBLING_BASENAME } from '../root-splat-sibling/basename'; const Main: React.FC = () => { return ( @@ -153,6 +154,19 @@ const Main: React.FC = () => { Wildcard No Heuristic + + Splat Sibling + + + Index Sibling + + + Empty Path Sibling + + {/* A separate React root, so a plain href rather than a routerLink. */} + + Root Splat Sibling + diff --git a/packages/react-router/test/base/src/pages/index-sibling/IndexSibling.tsx b/packages/react-router/test/base/src/pages/index-sibling/IndexSibling.tsx new file mode 100644 index 00000000000..3ebe9b0611f --- /dev/null +++ b/packages/react-router/test/base/src/pages/index-sibling/IndexSibling.tsx @@ -0,0 +1,93 @@ +import { + IonBackButton, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonItem, + IonLabel, + IonList, + IonPage, + IonRouterOutlet, + IonTitle, + IonToolbar, +} from '@ionic/react'; +import React, { useState } from 'react'; +import { Route, useParams } from 'react-router-dom'; + +import TestDescription from '../../components/TestDescription'; + +/** + * Companion to SplatSibling for the pathless shapes. An index route and an + * empty-path route both carry no path of their own, so they can be handed a + * pathname that a more specific sibling owns, the same way a splat can. + */ + +const Home: React.FC<{ pageId: string; detailHref: string }> = ({ pageId, detailHref }) => { + const [count, setCount] = useState(0); + + return ( + + + + Home + + + + setCount((c) => c + 1)}> + Increment + +
{count}
+ + + Open item 12 + + + + Increment the counter, then open item 12. The detail page should push over this page, leaving it mounted + behind. Going back should reveal this same page with the counter unchanged. + +
+
+ ); +}; + +const Detail: React.FC<{ pageId: string; backHref: string }> = ({ pageId, backHref }) => { + const { id } = useParams<{ id: string }>(); + + return ( + + + + + + + Detail + + + +
{id}
+
+
+ ); +}; + +/** Outlet whose home page is an index route. */ +export const IndexSibling: React.FC = () => { + return ( + + } /> + } /> + + ); +}; + +/** Outlet whose home page is an empty-path route. */ +export const EmptyPathSibling: React.FC = () => { + return ( + + } /> + } /> + + ); +}; diff --git a/packages/react-router/test/base/src/pages/splat-sibling/SplatSibling.tsx b/packages/react-router/test/base/src/pages/splat-sibling/SplatSibling.tsx new file mode 100644 index 00000000000..f2381a60c05 --- /dev/null +++ b/packages/react-router/test/base/src/pages/splat-sibling/SplatSibling.tsx @@ -0,0 +1,84 @@ +import { + IonBackButton, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonItem, + IonLabel, + IonList, + IonPage, + IonRouterOutlet, + IonTitle, + IonToolbar, +} from '@ionic/react'; +import React, { useState } from 'react'; +import { Route, useParams } from 'react-router-dom'; + +import TestDescription from '../../components/TestDescription'; + +/** + * A splat ("*") route that is a real container page rather than a 404, sharing an outlet + * with a more specific sibling. Pushing the sibling must leave the splat's page mounted + * behind it. + */ + +const Home: React.FC = () => { + const [count, setCount] = useState(0); + + return ( + + + + Splat Home + + + + setCount((c) => c + 1)}> + Increment + +
{count}
+ + + Open item 12 + + + + Increment the counter, then open item 12. The detail page should push over this page, leaving it mounted + behind. Going back should reveal this same page with the counter unchanged. + +
+
+ ); +}; + +const Detail: React.FC = () => { + const { id } = useParams<{ id: string }>(); + + return ( + + + + + + + Detail + + + +
{id}
+
+
+ ); +}; + +const SplatSibling: React.FC = () => { + return ( + + } /> + } /> + + ); +}; + +export default SplatSibling; diff --git a/packages/react-router/test/base/src/root-splat-sibling/RootSplatSiblingApp.tsx b/packages/react-router/test/base/src/root-splat-sibling/RootSplatSiblingApp.tsx new file mode 100644 index 00000000000..d0641cdd113 --- /dev/null +++ b/packages/react-router/test/base/src/root-splat-sibling/RootSplatSiblingApp.tsx @@ -0,0 +1,152 @@ +import { + IonApp, + IonBackButton, + IonButtons, + IonContent, + IonHeader, + IonIcon, + IonItem, + IonLabel, + IonList, + IonPage, + IonRouterOutlet, + IonTabBar, + IonTabButton, + IonTabs, + IonTitle, + IonToolbar, +} from '@ionic/react'; +import { IonReactRouter } from '@ionic/react-router'; +import { ellipse, triangle } from 'ionicons/icons'; +import React, { useState } from 'react'; +import { Navigate, Route, useParams } from 'react-router-dom'; + +/* Ionic CSS and setupIonicReact */ +import '../ionic-setup'; +import { ROOT_SPLAT_SIBLING_BASENAME } from './basename'; +import TestDescription from '../components/TestDescription'; + +/** + * A splat container page holding tabs, next to a more specific sibling that gets pushed over + * the whole tab bar, in the root outlet. + * + * A root catch-all can't live in the shared route tree in App.tsx without swallowing every + * other spec's pathname, so this app gets its own basename and index.tsx mounts it instead + * of App for that prefix. + */ + +/** + * The two spellings of a root splat fail differently, so "?splat=bare" runs the same flows + * against a bare "*" instead of "/*". Stamped onto the tabs page as data-splat so a test can + * confirm which spelling is live. + */ +const splatPath = new URLSearchParams(window.location.search).get('splat') === 'bare' ? '*' : '/*'; + +/** A fresh mount gets a fresh instance id, which tells a remount apart from a reveal. */ +let instanceCounter = 0; +const nextInstanceId = () => `tabs-${++instanceCounter}`; + +const Feed: React.FC = () => { + const [count, setCount] = useState(0); + + return ( + + + setCount((c) => c + 1)}> + Increment + + + Open item 12 + + +
{count}
+ + Increment the counter, then open item 12. The detail page should push over the whole tab bar, leaving these tabs + mounted behind it. Going back should reveal the same tabs with the counter unchanged. + +
+ ); +}; + +const FeedTab: React.FC = () => ( + + + + Feed + + + + +); + +const ProfileTab: React.FC = () => ( + + + + Profile + + + +
Profile
+
+
+); + +/** The container page the splat route renders. */ +const Tabs: React.FC = () => { + const [instanceId] = useState(nextInstanceId); + + return ( + // These land on both the .ion-page div and the ion-tabs element, so select with div.ion-page. + + + } /> + } /> + } /> + + + + + Feed + + + + Profile + + + + ); +}; + +const Detail: React.FC = () => { + const { id } = useParams<{ id: string }>(); + + return ( + + + + + + + Detail + + + +
{id}
+
+
+ ); +}; + +const RootSplatSiblingApp: React.FC = () => ( + + + + } /> + } /> + + + +); + +export default RootSplatSiblingApp; diff --git a/packages/react-router/test/base/src/root-splat-sibling/basename.ts b/packages/react-router/test/base/src/root-splat-sibling/basename.ts new file mode 100644 index 00000000000..3b4afe66ab5 --- /dev/null +++ b/packages/react-router/test/base/src/root-splat-sibling/basename.ts @@ -0,0 +1,8 @@ +/** + * The prefix the root splat sibling app is served under, appended to Vite's base so + * previews keep working. + * + * Kept apart from the app itself so Main.tsx can link to it without pulling a second + * IonReactRouter into App's module graph. + */ +export const ROOT_SPLAT_SIBLING_BASENAME = `${import.meta.env?.BASE_URL?.replace(/\/$/, '') || ''}/root-splat-sibling`; diff --git a/packages/react-router/test/base/tests/e2e/playwright/index-sibling.spec.ts b/packages/react-router/test/base/tests/e2e/playwright/index-sibling.spec.ts new file mode 100644 index 00000000000..9f7f4590ef0 --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/playwright/index-sibling.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from '@playwright/test'; + +import { ionBackClick, ionPageHidden, ionPageVisible, withTestingMode } from './utils/test-utils'; + +/** + * An index route and an empty-path route carry no path of their own, so like a splat they + * can be handed a pathname that a more specific sibling owns. The home page must stay + * mounted behind the pushed sibling either way. + * + * https://github.com/ionic-team/ionic-framework/issues/31477 + */ +const shapes = [ + { + name: 'index route', + base: '/index-sibling', + home: 'index-sibling-home', + detail: 'index-sibling-detail', + }, + { + name: 'empty path route', + base: '/empty-path-sibling', + home: 'empty-path-sibling-home', + detail: 'empty-path-sibling-detail', + }, +]; + +for (const { name, base, home, detail } of shapes) { + test.describe(`${name} with a more specific sibling`, () => { + test('keeps the home page mounted behind a pushed sibling', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31477', + }); + + await page.goto(withTestingMode(base)); + await ionPageVisible(page, home); + + await page.locator('[data-testid="open-detail"]').click(); + + await ionPageVisible(page, detail); + await expect(page.locator('[data-testid="detail-id"]')).toHaveText('12'); + await ionPageHidden(page, home); + }); + + test('restores the same home page with its state on back', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31477', + }); + + await page.goto(withTestingMode(base)); + await ionPageVisible(page, home); + + await page.locator('[data-testid="increment"]').click(); + await page.locator('[data-testid="increment"]').click(); + await page.locator('[data-testid="increment"]').click(); + await expect(page.locator('[data-testid="count"]')).toHaveText('3'); + + await page.locator('[data-testid="open-detail"]').click(); + await ionPageVisible(page, detail); + + await ionBackClick(page, detail); + + await ionPageVisible(page, home); + await expect(page.locator('[data-testid="count"]')).toHaveText('3'); + }); + }); +} diff --git a/packages/react-router/test/base/tests/e2e/playwright/root-splat-sibling.spec.ts b/packages/react-router/test/base/tests/e2e/playwright/root-splat-sibling.spec.ts new file mode 100644 index 00000000000..ab3ab73f6d5 --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/playwright/root-splat-sibling.spec.ts @@ -0,0 +1,151 @@ +import { test, expect } from '@playwright/test'; + +import { ionSwipeToGoBack } from './utils/drag-utils'; +import { ionBackClick, ionPageHidden, ionPageVisible, withTestingMode } from './utils/test-utils'; + +/** + * A splat route can be the outlet's container page rather than a 404. When a more specific + * sibling is pushed over it, the container must stay mounted behind so back reveals the same + * page with its state intact. splat-sibling.spec.ts covers that one level down; this covers + * it in the root outlet, which is served under its own basename. + * + * https://github.com/ionic-team/ionic-framework/issues/31477 + */ +test.describe('root splat route with a more specific sibling', () => { + test('keeps the root splat tabs mounted behind a pushed sibling', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31477', + }); + + await page.goto(withTestingMode('/root-splat-sibling/feed')); + await ionPageVisible(page, 'root-splat-sibling-tabs'); + await ionPageVisible(page, 'root-splat-sibling-feed'); + + await page.locator('[data-testid="open-detail"]').click(); + + await ionPageVisible(page, 'root-splat-sibling-detail'); + await expect(page.locator('[data-testid="detail-id"]')).toHaveText('12'); + + // The tabs are the page underneath, so they stay in the DOM and are just hidden. + await ionPageHidden(page, 'root-splat-sibling-tabs'); + await expect(page.locator('ion-tabs')).toHaveCount(1); + }); + + test('reveals the same tabs page with its state on back', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31477', + }); + + await page.goto(withTestingMode('/root-splat-sibling/feed')); + await ionPageVisible(page, 'root-splat-sibling-feed'); + + const tabsPage = page.locator('div.ion-page[data-pageid="root-splat-sibling-tabs"]'); + // Pins the default spelling, so inverting the ternary fails here and not only in the bare test. + await expect(tabsPage).toHaveAttribute('data-splat', '/*'); + const originalInstance = await tabsPage.getAttribute('data-instance'); + + await page.locator('[data-testid="increment"]').click(); + await page.locator('[data-testid="increment"]').click(); + await expect(page.locator('[data-testid="count"]')).toHaveText('2'); + + await page.locator('[data-testid="open-detail"]').click(); + await ionPageVisible(page, 'root-splat-sibling-detail'); + + await ionBackClick(page, 'root-splat-sibling-detail'); + + await ionPageVisible(page, 'root-splat-sibling-tabs'); + await ionPageVisible(page, 'root-splat-sibling-feed'); + // A replacement page would carry a fresh instance id and a counter back at 0. + await expect(tabsPage).toHaveAttribute('data-instance', originalInstance!); + await expect(page.locator('[data-testid="count"]')).toHaveText('2'); + }); + + // Animations stay on here, so the gesture reveals the page underneath rather than a + // commit doing it. + test('reveals the tabs page while swiping back', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31477', + }); + + await page.goto('/root-splat-sibling/feed?ionic:mode=ios'); + await ionPageVisible(page, 'root-splat-sibling-feed'); + + await page.locator('[data-testid="increment"]').click(); + await expect(page.locator('[data-testid="count"]')).toHaveText('1'); + + await page.locator('[data-testid="open-detail"]').click(); + await ionPageVisible(page, 'root-splat-sibling-detail'); + // ionPageHidden resolves early here, because the deactivation scan applies + // ion-page-hidden at render time rather than on commit, so wait out the push + // transition before starting the gesture. + await ionPageHidden(page, 'root-splat-sibling-tabs'); + await page.waitForTimeout(600); + + await ionSwipeToGoBack(page, true, 'ion-router-outlet#root-splat-sibling-outlet'); + + await ionPageVisible(page, 'root-splat-sibling-tabs'); + await expect(page.locator('[data-testid="count"]')).toHaveText('1'); + }); + + // A bare "*" fails differently from "/*", so run the same flows against both spellings. + test('keeps a bare "*" container mounted and restores it on back', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31477', + }); + + await page.goto(withTestingMode('/root-splat-sibling/feed?splat=bare')); + await ionPageVisible(page, 'root-splat-sibling-feed'); + + const tabsPage = page.locator('div.ion-page[data-pageid="root-splat-sibling-tabs"]'); + // The query param is the only thing selecting the bare spelling, and navigation drops it. + await expect(tabsPage).toHaveAttribute('data-splat', '*'); + const originalInstance = await tabsPage.getAttribute('data-instance'); + + await page.locator('[data-testid="increment"]').click(); + await expect(page.locator('[data-testid="count"]')).toHaveText('1'); + + await page.locator('[data-testid="open-detail"]').click(); + await ionPageVisible(page, 'root-splat-sibling-detail'); + await ionPageHidden(page, 'root-splat-sibling-tabs'); + + await ionBackClick(page, 'root-splat-sibling-detail'); + + await ionPageVisible(page, 'root-splat-sibling-tabs'); + await expect(tabsPage).toHaveAttribute('data-instance', originalInstance!); + await expect(page.locator('[data-testid="count"]')).toHaveText('1'); + }); + + // Revealing the container isn't enough on its own, the tabs underneath have to still + // route after back. + test('leaves the revealed container routable through its tab bar', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31477', + }); + + await page.goto(withTestingMode('/root-splat-sibling/feed')); + await ionPageVisible(page, 'root-splat-sibling-feed'); + + await page.locator('[data-testid="increment"]').click(); + await expect(page.locator('[data-testid="count"]')).toHaveText('1'); + + await page.locator('[data-testid="open-detail"]').click(); + await ionPageVisible(page, 'root-splat-sibling-detail'); + + await ionBackClick(page, 'root-splat-sibling-detail'); + await ionPageVisible(page, 'root-splat-sibling-feed'); + + await page.locator('[data-testid="tab-profile"]').click(); + await ionPageVisible(page, 'root-splat-sibling-profile'); + await expect(page.locator('[data-testid="profile-content"]')).toBeVisible(); + + await page.locator('[data-testid="tab-feed"]').click(); + await ionPageVisible(page, 'root-splat-sibling-feed'); + // Tabs keep their pages mounted, so the counter survives the round trip too. + await expect(page.locator('[data-testid="count"]')).toHaveText('1'); + }); +}); diff --git a/packages/react-router/test/base/tests/e2e/playwright/splat-sibling.spec.ts b/packages/react-router/test/base/tests/e2e/playwright/splat-sibling.spec.ts new file mode 100644 index 00000000000..833c9bab16d --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/playwright/splat-sibling.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from '@playwright/test'; + +import { ionSwipeToGoBack } from './utils/drag-utils'; +import { ionBackClick, ionPageHidden, ionPageVisible, withTestingMode } from './utils/test-utils'; + +/** + * A splat ("*") route can be a real container page, not just a 404. When a more + * specific sibling route in the same outlet is pushed, the splat's page must stay + * mounted behind it so back reveals the same page with its state intact. + * + * https://github.com/ionic-team/ionic-framework/issues/31477 + */ +test.describe('splat route with a more specific sibling', () => { + test('keeps the splat page mounted behind a pushed sibling', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31477', + }); + + await page.goto(withTestingMode('/splat-sibling')); + await ionPageVisible(page, 'splat-sibling-home'); + + await page.locator('[data-testid="open-detail"]').click(); + + await ionPageVisible(page, 'splat-sibling-detail'); + await expect(page.locator('[data-testid="detail-id"]')).toHaveText('12'); + await ionPageHidden(page, 'splat-sibling-home'); + }); + + test('restores the same splat page with its state on back', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31477', + }); + + await page.goto(withTestingMode('/splat-sibling')); + await ionPageVisible(page, 'splat-sibling-home'); + + await page.locator('[data-testid="increment"]').click(); + await page.locator('[data-testid="increment"]').click(); + await page.locator('[data-testid="increment"]').click(); + await expect(page.locator('[data-testid="count"]')).toHaveText('3'); + + await page.locator('[data-testid="open-detail"]').click(); + await ionPageVisible(page, 'splat-sibling-detail'); + + await ionBackClick(page, 'splat-sibling-detail'); + + await ionPageVisible(page, 'splat-sibling-home'); + await expect(page.locator('[data-testid="count"]')).toHaveText('3'); + }); + + // Animations stay on here, so the gesture reveals the page underneath rather than a + // commit doing it. + test('reveals the splat page while swiping back', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31477', + }); + + await page.goto('/splat-sibling?ionic:mode=ios'); + await ionPageVisible(page, 'splat-sibling-home'); + + await page.locator('[data-testid="increment"]').click(); + await expect(page.locator('[data-testid="count"]')).toHaveText('1'); + + await page.locator('[data-testid="open-detail"]').click(); + await ionPageVisible(page, 'splat-sibling-detail'); + // ionPageHidden resolves early here, because the deactivation scan applies + // ion-page-hidden at render time rather than on commit, so wait out the push + // transition before starting the gesture. + await ionPageHidden(page, 'splat-sibling-home'); + await page.waitForTimeout(600); + + await ionSwipeToGoBack(page, true, 'ion-router-outlet#splat-sibling-outlet'); + + await ionPageVisible(page, 'splat-sibling-home'); + await expect(page.locator('[data-testid="count"]')).toHaveText('1'); + }); +});