diff --git a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx index b5fb9f843d..798cd58228 100644 --- a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx +++ b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx @@ -107,9 +107,10 @@ export class ReactRouterViewStack extends ViewStacks { return children; } - findViewItemByRouteInfo(routeInfo: RouteInfo, outletId?: string) { + findViewItemByRouteInfo(routeInfo: RouteInfo, outletId?: string, updateMatch?: boolean) { const { viewItem, match } = this.findViewItemByPath(routeInfo.pathname, outletId); - if (viewItem && match) { + const shouldUpdateMatch = updateMatch === undefined || updateMatch === true; + if (shouldUpdateMatch && viewItem && match) { viewItem.routeData.match = match; } return viewItem; diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index a4f3f072e0..01c2b3641c 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -25,6 +25,8 @@ export class StackManager extends React.PureComponent; ionRouterOutlet?: React.ReactElement; routerOutletElement: HTMLIonRouterOutletElement | undefined; + prevProps?: StackManagerProps; + skipTransition: boolean; stackContextValue: StackContextState = { registerIonPage: this.registerIonPage.bind(this), @@ -39,6 +41,8 @@ export class StackManager extends React.PureComponent { const config = getConfig(); const swipeEnabled = config && config.get('swipeBackEnabled', routerOutlet.mode === 'ios'); - if (swipeEnabled) { - return this.context.canGoBack(); - } else { - return false; - } + if (!swipeEnabled) { return false; } + + const { routeInfo } = this.props; + + const propsToUse = (this.prevProps && this.prevProps.routeInfo.pathname === routeInfo.pushedByRoute) ? this.prevProps.routeInfo : { pathname: routeInfo.pushedByRoute || '' } as any; + const enteringViewItem = this.context.findViewItemByRouteInfo(propsToUse, this.id, false); + + return ( + !!enteringViewItem && + /** + * The root url '/' is treated as + * the first view item (but is never mounted), + * so we do not want to swipe back to the + * root url. + */ + enteringViewItem.mount && + + /** + * When on the first page (whatever view + * you land on after the root url) it + * is possible for findViewItemByRouteInfo to + * return the exact same view you are currently on. + * Make sure that we are not swiping back to the same + * instances of a view. + */ + enteringViewItem.routeData.match.path !== routeInfo.pathname + ); }; - const onStart = () => { - this.context.goBack(); + const onStart = async () => { + const { routeInfo } = this.props; + + const propsToUse = (this.prevProps && this.prevProps.routeInfo.pathname === routeInfo.pushedByRoute) ? this.prevProps.routeInfo : { pathname: routeInfo.pushedByRoute || '' } as any; + const enteringViewItem = this.context.findViewItemByRouteInfo(propsToUse, this.id, false); + const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false); + + /** + * When the gesture starts, kick off + * a transition that is controlled + * via a swipe gesture. + */ + if (enteringViewItem && leavingViewItem) { + await this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back', true); + } + + return Promise.resolve(); }; + const onEnd = (shouldContinue: boolean) => { + if (shouldContinue) { + this.skipTransition = true; + + this.context.goBack(); + } else { + /** + * In the event that the swipe + * gesture was aborted, we should + * re-hide the page that was going to enter. + */ + const { routeInfo } = this.props; + + const propsToUse = (this.prevProps && this.prevProps.routeInfo.pathname === routeInfo.pushedByRoute) ? this.prevProps.routeInfo : { pathname: routeInfo.pushedByRoute || '' } as any; + const enteringViewItem = this.context.findViewItemByRouteInfo(propsToUse, this.id, false); + const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false); + + /** + * Ionic React has a design defect where it + * a) Unmounts the leaving view item when using parameterized routes + * b) Considers the current view to be the entering view when using + * parameterized routes + * + * As a result, we should not hide the view item here + * as it will cause the current view to be hidden. + */ + if ( + enteringViewItem !== leavingViewItem && + enteringViewItem?.ionPageElement !== undefined + ) { + const { ionPageElement } = enteringViewItem; + ionPageElement.setAttribute('aria-hidden', 'true'); + ionPageElement.classList.add('ion-page-hidden'); + } + } + } + routerOutlet.swipeHandler = { canStart, onStart, - onEnd: (_shouldContinue) => true, + onEnd }; } async transitionPage( routeInfo: RouteInfo, enteringViewItem: ViewItem, - leavingViewItem?: ViewItem + leavingViewItem?: ViewItem, + direction?: 'forward' | 'back', + progressAnimation = false ) { + const runCommit = async (enteringEl: HTMLElement, leavingEl?: HTMLElement) => { + const skipTransition = this.skipTransition; + + /** + * If the transition was handled + * via the swipe to go back gesture, + * then we do not want to perform + * another transition. + * + * We skip adding ion-page or ion-page-invisible + * because the entering view already exists in the DOM. + * If we added the classes, there would be a flicker where + * the view would be briefly hidden. + */ + if (skipTransition) { + /** + * We need to reset skipTransition before + * we call routerOutlet.commit otherwise + * the transition triggered by the swipe + * to go back gesture would reset it. In + * that case you would see a duplicate + * transition triggered by handlePageTransition + * in componentDidUpdate. + */ + this.skipTransition = false; + } else { + enteringEl.classList.add('ion-page'); + enteringEl.classList.add('ion-page-invisible'); + } + + await routerOutlet.commit(enteringEl, leavingEl, { + deepWait: true, + duration: skipTransition || directionToUse === undefined ? 0 : undefined, + direction: directionToUse, + showGoBack: !!routeInfo.pushedByRoute, + progressAnimation, + animationBuilder: routeInfo.routeAnimation, + }); + } + const routerOutlet = this.routerOutletElement!; - const direction = + const routeInfoFallbackDirection = routeInfo.routeDirection === 'none' || routeInfo.routeDirection === 'root' ? undefined : routeInfo.routeDirection; + const directionToUse = direction ?? routeInfoFallbackDirection; if (enteringViewItem && enteringViewItem.ionPageElement && this.routerOutletElement) { if ( @@ -238,26 +365,12 @@ export class StackManager extends React.PureComponent { This spec tests that swipe to go back works */ - it('/swipe-to-go-back, ', () => { + it('should swipe and abort', () => { cy.visit(`http://localhost:${port}/swipe-to-go-back`); cy.ionPageVisible('main'); + cy.ionNav('ion-item', 'Details'); cy.ionPageVisible('details'); cy.ionPageHidden('main'); - cy.ionSwipeToGoBack(true); + + cy.ionSwipeToGoBack(false, 'ion-router-outlet#swipe-to-go-back'); + cy.ionPageVisible('details'); + cy.ionPageHidden('main'); + }); + + it('should swipe and go back', () => { + cy.visit(`http://localhost:${port}/swipe-to-go-back`); + cy.ionPageVisible('main'); + + cy.ionNav('ion-item', 'Details'); + cy.ionPageVisible('details'); + cy.ionPageHidden('main'); + + cy.ionSwipeToGoBack(true, 'ion-router-outlet#swipe-to-go-back'); cy.ionPageVisible('main'); }); + + it('should swipe and abort within a tab', () => { + cy.visit(`http://localhost:${port}/tabs/tab1`); + cy.ionPageVisible('tab1'); + + cy.get('#child-one').click(); + cy.ionPageHidden('tab1'); + cy.ionPageVisible('tab1child1'); + + cy.ionSwipeToGoBack(false, 'ion-tabs ion-router-outlet'); + + cy.ionPageHidden('tab1'); + cy.ionPageVisible('tab1child1') + }); + + it('should swipe and go back within a tab', () => { + cy.visit(`http://localhost:${port}/tabs/tab1`); + cy.ionPageVisible('tab1'); + + cy.get('#child-one').click(); + cy.ionPageHidden('tab1'); + cy.ionPageVisible('tab1child1'); + + cy.ionSwipeToGoBack(true, 'ion-tabs ion-router-outlet'); + + cy.ionPageVisible('tab1'); + cy.ionPageDoesNotExist('tab1child1') + }); + + it('should swipe and go back to correct tab after switching tabs', () => { + cy.visit(`http://localhost:${port}`); + cy.ionPageVisible('home'); + + cy.get('#go-to-tabs').click(); + cy.ionPageHidden('home'); + cy.ionPageVisible('tab1'); + cy.ionPageVisible('tabs'); + + cy.get('#child-one').click(); + cy.ionPageHidden('tab1'); + cy.ionPageVisible('tab1child1'); + + cy.get('ion-tab-button#tab-button-tab2').click(); + cy.ionPageVisible('tab2'); + cy.ionPageHidden('tab1child1'); + + cy.get('ion-tab-button#tab-button-tab1').click(); + cy.ionPageVisible('tab1child1'); + cy.ionPageHidden('tab2'); + + cy.ionSwipeToGoBack(true, 'ion-tabs ion-router-outlet'); + + cy.ionPageVisible('tab1'); + cy.ionPageDoesNotExist('tab1child1'); + + cy.ionSwipeToGoBack(true, 'ion-tabs ion-router-outlet'); + cy.ionPageVisible('home'); + cy.ionPageDoesNotExist('tabs'); + }); + + it('should be able to swipe back from child tab page after visiting', () => { + cy.visit(`http://localhost:${port}/tabs/tab1`); + cy.ionPageVisible('tab1'); + + cy.get('#child-one').click(); + cy.ionPageHidden('tab1'); + cy.ionPageVisible('tab1child1'); + + cy.get('#child-two').click(); + cy.ionPageHidden('tab1child1'); + cy.ionPageVisible('tab1child2'); + + cy.ionSwipeToGoBack(true, 'ion-tabs ion-router-outlet'); + + cy.ionPageDoesNotExist('tab1child2'); + cy.ionPageVisible('tab1child1'); + + cy.ionSwipeToGoBack(true, 'ion-tabs ion-router-outlet'); + + cy.ionPageDoesNotExist('tab1child1'); + cy.ionPageVisible('tab1'); + + cy.get('#child-one').click(); + cy.ionPageHidden('tab1'); + cy.ionPageVisible('tab1child1'); + + cy.ionSwipeToGoBack(true, 'ion-tabs ion-router-outlet'); + + cy.ionPageDoesNotExist('tab1child1'); + cy.ionPageVisible('tab1'); + }) + + it('should not swipe to go back to the same view you are on', () => { + cy.visit(`http://localhost:${port}`); + cy.ionPageVisible('home'); + + cy.ionSwipeToGoBack(false); + cy.ionPageVisible('home'); + }) + + it('should not hide a parameterized page when swiping and aborting', () => { + cy.visit(`http://localhost:${port}/params/0`); + cy.ionPageVisible('params-0'); + + cy.get('#next-page').click(); + cy.ionPageVisible('params-1'); + + cy.ionSwipeToGoBack(false); + + cy.ionPageVisible('params-1'); + }) }); diff --git a/packages/react-router/test-app/cypress/support/commands.js b/packages/react-router/test-app/cypress/support/commands.js index 8b9fc58cba..fd0168ef73 100644 --- a/packages/react-router/test-app/cypress/support/commands.js +++ b/packages/react-router/test-app/cypress/support/commands.js @@ -102,7 +102,7 @@ Cypress.Commands.add('ionMenuNav', (contains) => { Cypress.Commands.add('ionTabClick', (tabText) => { // TODO: figure out how to get rid of this wait. Switching tabs after a forward nav to a details page needs it - cy.wait(250); + cy.wait(500); cy.contains('ion-tab-button', tabText).click({ force: true }); // cy.get('ion-tab-button.tab-selected').contains(tabText) }); @@ -126,4 +126,4 @@ Cypress.Commands.add('ionMenuClick', () => { Cypress.Commands.add('ionHardwareBackEvent', () => { cy.document().trigger('backbutton'); -}); \ No newline at end of file +}); diff --git a/packages/react-router/test-app/src/App.tsx b/packages/react-router/test-app/src/App.tsx index 617bbf09af..3a2314f285 100644 --- a/packages/react-router/test-app/src/App.tsx +++ b/packages/react-router/test-app/src/App.tsx @@ -1,4 +1,4 @@ -import { IonApp, setupIonicReact } from '@ionic/react'; +import { IonApp, setupIonicReact, IonRouterOutlet } from '@ionic/react'; import React from 'react'; import { Route } from 'react-router-dom'; @@ -36,6 +36,7 @@ import Refs from './pages/refs/Refs'; import DynamicIonpageClassnames from './pages/dynamic-ionpage-classnames/DynamicIonpageClassnames'; import Tabs from './pages/tabs/Tabs'; import TabsSecondary from './pages/tabs/TabsSecondary'; +import Params from './pages/params/Params'; setupIonicReact(); @@ -43,21 +44,24 @@ const App: React.FC = () => { return ( - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + ); diff --git a/packages/react-router/test-app/src/pages/Main.tsx b/packages/react-router/test-app/src/pages/Main.tsx index 6075da45f5..83c882d0eb 100644 --- a/packages/react-router/test-app/src/pages/Main.tsx +++ b/packages/react-router/test-app/src/pages/Main.tsx @@ -14,7 +14,7 @@ interface MainProps {} const Main: React.FC = () => { return ( - + Main @@ -58,6 +58,12 @@ const Main: React.FC = () => { Refs + + Tabs + + + Params + diff --git a/packages/react-router/test-app/src/pages/params/Params.tsx b/packages/react-router/test-app/src/pages/params/Params.tsx new file mode 100644 index 0000000000..18f49d4588 --- /dev/null +++ b/packages/react-router/test-app/src/pages/params/Params.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { + IonButtons, + IonBackButton, + IonButton, + IonContent, + IonHeader, + IonPage, + IonTitle, + IonToolbar, +} from '@ionic/react'; +import { RouteComponentProps } from 'react-router'; + +interface PageProps +extends RouteComponentProps<{ + id: string; +}> {} + + +const Page: React.FC = ({ match }) => { + const parseID = parseInt(match.params.id); + return ( + + + + Params { match.params.id } + + + + + + + Go to next param +
+ Page ID: { match.params.id } +
+
+ ); +}; + +export default Page; diff --git a/packages/react-router/test-app/src/pages/tabs/Tabs.tsx b/packages/react-router/test-app/src/pages/tabs/Tabs.tsx index f6a2fa3ab0..d27907821d 100644 --- a/packages/react-router/test-app/src/pages/tabs/Tabs.tsx +++ b/packages/react-router/test-app/src/pages/tabs/Tabs.tsx @@ -22,11 +22,12 @@ interface TabsProps {} const Tabs: React.FC = () => { return ( - + + @@ -71,7 +72,26 @@ const Tab1Child1 = () => { + Tab 1 Child 1 + Go to Tab1Child2 + +
+ ); +}; +const Tab1Child2 = () => { + return ( + + + + + + + Tab1 + + + + Tab 1 Child 2 ); diff --git a/packages/react/src/routing/RouteManagerContext.ts b/packages/react/src/routing/RouteManagerContext.ts index 044fd7de8b..a37eb22bb2 100644 --- a/packages/react/src/routing/RouteManagerContext.ts +++ b/packages/react/src/routing/RouteManagerContext.ts @@ -16,7 +16,7 @@ export interface RouteManagerContextState { ) => ViewItem; findViewItemByPathname(pathname: string, outletId?: string): ViewItem | undefined; findLeavingViewItemByRouteInfo: (routeInfo: RouteInfo, outletId?: string) => ViewItem | undefined; - findViewItemByRouteInfo: (routeInfo: RouteInfo, outletId?: string) => ViewItem | undefined; + findViewItemByRouteInfo: (routeInfo: RouteInfo, outletId?: string, updateMatch?: boolean) => ViewItem | undefined; getChildrenToRender: ( outletId: string, ionRouterOutlet: React.ReactElement, diff --git a/packages/react/src/routing/ViewStacks.ts b/packages/react/src/routing/ViewStacks.ts index 6bcbc20787..59e6afe961 100644 --- a/packages/react/src/routing/ViewStacks.ts +++ b/packages/react/src/routing/ViewStacks.ts @@ -65,7 +65,7 @@ export abstract class ViewStacks { page?: HTMLElement ): ViewItem; abstract findViewItemByPathname(pathname: string, outletId?: string): ViewItem | undefined; - abstract findViewItemByRouteInfo(routeInfo: RouteInfo, outletId?: string): ViewItem | undefined; + abstract findViewItemByRouteInfo(routeInfo: RouteInfo, outletId?: string, updateMatch?: boolean): ViewItem | undefined; abstract findLeavingViewItemByRouteInfo( routeInfo: RouteInfo, outletId?: string