From b8545926ac7175542b64ffbced886631b57afae6 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 8 Oct 2026 09:55:11 -0700 Subject: [PATCH] fix(nav): defer non-animated swipe back until the gesture completes --- core/src/components/nav/nav.tsx | 32 +++- .../components/nav/test/swipe-back/nav.e2e.ts | 153 ++++++++++++++++++ .../router-outlet/router-outlet.tsx | 36 ++++- .../test/swipe-back/router-outlet.e2e.ts | 43 +++++ core/src/utils/transition/index.ts | 13 ++ .../swipe-animated-disabled.spec.ts | 123 ++++++++++++++ .../standalone/app-standalone/app.routes.ts | 8 + .../home-page/home-page.component.html | 5 + ...ipe-animated-disabled-details.component.ts | 14 ++ .../swipe-animated-disabled-main.component.ts | 30 ++++ .../swipe-animated-disabled.component.ts | 29 ++++ .../src/ReactRouter/StackManager.tsx | 8 +- .../e2e/playwright/swipe-to-go-back.spec.ts | 48 +++++- .../tests/e2e/playwright/swipe-back.spec.ts | 41 +++++ 14 files changed, 573 insertions(+), 10 deletions(-) create mode 100644 core/src/components/nav/test/swipe-back/nav.e2e.ts create mode 100644 core/src/components/router-outlet/test/swipe-back/router-outlet.e2e.ts create mode 100644 packages/angular/test/base/e2e/src/standalone/swipe-animated-disabled.spec.ts create mode 100644 packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-details.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-main.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled.component.ts create mode 100644 packages/vue/test/base/tests/e2e/playwright/swipe-back.spec.ts diff --git a/core/src/components/nav/nav.tsx b/core/src/components/nav/nav.tsx index 2892d7030dc..20aacd6d763 100644 --- a/core/src/components/nav/nav.tsx +++ b/core/src/components/nav/nav.tsx @@ -4,7 +4,7 @@ import { getTimeGivenProgression } from '@utils/animation/cubic-bezier'; import { assert } from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; import type { TransitionOptions } from '@utils/transition'; -import { lifecycle, setPageHidden, transition } from '@utils/transition'; +import { blockSwipeClicks, lifecycle, setPageHidden, transition } from '@utils/transition'; import { config } from '../../global/config'; import { getIonMode } from '../../global/ionic-global'; @@ -30,6 +30,7 @@ import { VIEW_STATE_ATTACHED, VIEW_STATE_DESTROYED, VIEW_STATE_NEW, convertToVie export class Nav implements ComponentInterface { private transInstr: TransitionInstruction[] = []; private sbAni?: Animation; + private unblockSwipeClicks?: () => void; private gestureOrAnimationInProgress = false; private isTransitioning = false; private destroyed = false; @@ -145,6 +146,14 @@ export class Nav implements ComponentInterface { this.gesture.destroy(); this.gesture = undefined; } + + // Destroying the gesture skips onEnd, so end a deferred swipe here. + if (this.unblockSwipeClicks) { + this.unblockSwipeClicks(); + this.unblockSwipeClicks = undefined; + this.gestureOrAnimationInProgress = false; + } + this.transInstr.length = 0; this.views.length = 0; this.destroyed = true; @@ -919,6 +928,16 @@ export class Nav implements ComponentInterface { private onStart() { this.gestureOrAnimationInProgress = true; + + /** + * Without animations there's nothing for the swipe to drive, so + * wait until the gesture ends to decide whether to go back. + */ + if (!(this.animated && config.getBoolean('animated', true))) { + this.unblockSwipeClicks = blockSwipeClicks(this.el); + return; + } + this.pop({ direction: 'back', progressAnimation: true }); } @@ -958,6 +977,17 @@ export class Nav implements ComponentInterface { this.sbAni.progressEnd(shouldComplete ? 1 : 0, newStepValue, dur); } else { this.gestureOrAnimationInProgress = false; + + const { unblockSwipeClicks } = this; + if (unblockSwipeClicks) { + this.unblockSwipeClicks = undefined; + + if (shouldComplete) { + this.pop({ direction: 'back' }); + } + + unblockSwipeClicks(); + } } } diff --git a/core/src/components/nav/test/swipe-back/nav.e2e.ts b/core/src/components/nav/test/swipe-back/nav.e2e.ts new file mode 100644 index 00000000000..ebcb44e934c --- /dev/null +++ b/core/src/components/nav/test/swipe-back/nav.e2e.ts @@ -0,0 +1,153 @@ +import { expect } from '@playwright/test'; +import type { E2EPage, E2EPageOptions } from '@utils/test/playwright'; +import { configs, dragElementBy, test } from '@utils/test/playwright'; + +/** + * Renders an `ion-nav` and pushes a second page. The `extraScript` + * runs before Ionic loads, so it can change the global config. + */ +const pushSecondPage = async (page: E2EPage, config: E2EPageOptions, navAttrs = '', extraScript = '') => { + await page.setContent( + ` + + + + + `, + config + ); + + const nav = page.locator('ion-nav'); + await nav.evaluate((el: HTMLIonNavElement) => el.push((window as any).createPage('page-two'))); + await expect(page.locator('#page-two')).toBeVisible(); + + return nav; +}; + +/** + * Starts a swipe to go back and lets go partway across, so the user + * should stay on the current page. + */ +const abandonSwipeBack = async (page: E2EPage) => { + const nav = page.locator('ion-nav'); + const box = (await nav.boundingBox())!; + const startX = box.x + 5; + const y = box.y + box.height / 2; + + await dragElementBy(nav, page, 60, 0, startX, y, false); + + /** + * A quick drag is released with enough velocity to complete + * the swipe, so slow down before letting go. + */ + for (let i = 1; i <= 3; i++) { + await page.mouse.move(startX + 60 + i, y); + await page.waitForTimeout(50); + } + + await page.mouse.up(); + await page.waitForChanges(); +}; + +/** + * Swipe to go back is only enabled by default in iOS mode, + * and this behavior does not vary across directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('nav: swipe back'), () => { + test('should not pop the page when an abandoned swipe starts on a non-animated nav', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + const nav = await pushSecondPage(page, config, 'animated="false"'); + + await abandonSwipeBack(page); + + await expect(page.locator('#page-two')).toBeVisible(); + expect(await nav.evaluate((el: HTMLIonNavElement) => el.getLength())).toBe(2); + }); + + test('should not pop the page when an abandoned swipe starts with animations disabled globally', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + const nav = await pushSecondPage(page, config, '', 'window.Ionic.config.animated = false;'); + + await abandonSwipeBack(page); + + await expect(page.locator('#page-two')).toBeVisible(); + expect(await nav.evaluate((el: HTMLIonNavElement) => el.getLength())).toBe(2); + }); + + test('should not click the page when an abandoned swipe is released on a non-animated nav', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await pushSecondPage(page, config, 'animated="false"'); + + await page.locator('#page-two').evaluate((pageTwo) => { + const button = document.createElement('button'); + button.style.cssText = 'position: absolute; inset: 0; width: 100%; height: 100%;'; + button.addEventListener('click', () => ((window as any).pageClicked = true)); + pageTwo.appendChild(button); + }); + + await abandonSwipeBack(page); + + expect(await page.evaluate(() => (window as any).pageClicked)).toBeUndefined(); + }); + + test('should not block clicks on a non-animated nav that is moved during a swipe', async ({ page }) => { + const nav = await pushSecondPage(page, config, 'animated="false"'); + const box = (await nav.boundingBox())!; + + await dragElementBy(nav, page, 60, 0, box.x + 5, box.y + box.height / 2, false); + await expect(nav).toHaveCSS('pointer-events', 'none'); + + await nav.evaluate((el) => document.body.appendChild(el)); + + await expect(nav).not.toHaveCSS('pointer-events', 'none'); + await page.mouse.up(); + }); + + test('should pop the page when a swipe completes on a non-animated nav', async ({ page }) => { + const nav = await pushSecondPage(page, config, 'animated="false"'); + const box = (await nav.boundingBox())!; + + await dragElementBy(nav, page, box.width * 0.75, 0, box.x + 5); + + await expect.poll(() => nav.evaluate((el: HTMLIonNavElement) => el.getLength())).toBe(1); + await expect(page.locator('#page-one')).toBeVisible(); + }); + + test('should pop the page when a swipe completes on an animated nav', async ({ page }) => { + const nav = await pushSecondPage(page, config); + const box = (await nav.boundingBox())!; + + await dragElementBy(nav, page, box.width * 0.75, 0, box.x + 5); + + await expect.poll(() => nav.evaluate((el: HTMLIonNavElement) => el.getLength())).toBe(1); + await expect(page.locator('#page-one')).toBeVisible(); + }); + }); +}); diff --git a/core/src/components/router-outlet/router-outlet.tsx b/core/src/components/router-outlet/router-outlet.tsx index 1163a4bd4c4..7f1bceb7e7c 100644 --- a/core/src/components/router-outlet/router-outlet.tsx +++ b/core/src/components/router-outlet/router-outlet.tsx @@ -5,7 +5,7 @@ import { attachComponent, detachComponent } from '@utils/framework-delegate'; import { shallowEqualStringMap, hasLazyBuild } from '@utils/helpers'; import { createLockController } from '@utils/lock-controller'; import { printIonError } from '@utils/logging'; -import { transition } from '@utils/transition'; +import { blockSwipeClicks, transition } from '@utils/transition'; import { config } from '../../global/config'; import { getIonMode } from '../../global/ionic-global'; @@ -33,6 +33,7 @@ export class RouterOutlet implements ComponentInterface, NavOutlet { private activeParams: any; private gesture?: Gesture; private ani?: Animation; + private unblockSwipeClicks?: () => void; private gestureOrAnimationInProgress = false; @Element() el!: HTMLElement; @@ -82,6 +83,16 @@ export class RouterOutlet implements ComponentInterface, NavOutlet { async connectedCallback() { const onStart = () => { this.gestureOrAnimationInProgress = true; + + /** + * Without animations there's nothing for the swipe to drive, so + * wait until the gesture ends to decide whether to go back. + */ + if (!(this.animated && config.getBoolean('animated', true))) { + this.unblockSwipeClicks = blockSwipeClicks(this.el); + return; + } + if (this.swipeHandler) { this.swipeHandler.onStart(); } @@ -125,7 +136,21 @@ export class RouterOutlet implements ComponentInterface, NavOutlet { this.ani.progressEnd(shouldComplete ? 1 : 0, newStepValue, dur); } else { - this.gestureOrAnimationInProgress = false; + const { swipeHandler, unblockSwipeClicks: unblock } = this; + this.unblockSwipeClicks = undefined; + + // The stack can change during the swipe, so check that going back is still possible. + if (unblock && shouldComplete && swipeHandler?.canStart()) { + new Promise((resolve) => resolve(swipeHandler.onStart())) + .then(() => swipeHandler.onEnd(true)) + .finally(() => { + this.gestureOrAnimationInProgress = false; + unblock(); + }); + } else { + this.gestureOrAnimationInProgress = false; + unblock?.(); + } } } ); @@ -152,6 +177,13 @@ export class RouterOutlet implements ComponentInterface, NavOutlet { this.gesture.destroy(); this.gesture = undefined; } + + // Destroying the gesture skips onEnd, so end a deferred swipe here. + if (this.unblockSwipeClicks) { + this.unblockSwipeClicks(); + this.unblockSwipeClicks = undefined; + this.gestureOrAnimationInProgress = false; + } } /** @internal */ diff --git a/core/src/components/router-outlet/test/swipe-back/router-outlet.e2e.ts b/core/src/components/router-outlet/test/swipe-back/router-outlet.e2e.ts new file mode 100644 index 00000000000..a5aac151ec1 --- /dev/null +++ b/core/src/components/router-outlet/test/swipe-back/router-outlet.e2e.ts @@ -0,0 +1,43 @@ +import { expect } from '@playwright/test'; +import { configs, dragElementBy, test } from '@utils/test/playwright'; + +/** + * Swipe to go back is only enabled by default in iOS mode, + * and this behavior does not vary across directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('router-outlet: swipe back'), () => { + test('should not block clicks on a non-animated outlet that is moved during a swipe', async ({ page }) => { + await page.setContent( + ` + + + + `, + config + ); + + const outlet = page.locator('ion-router-outlet'); + + // A framework normally provides the handler that lets the gesture start. + await outlet.evaluate((el: HTMLIonRouterOutletElement) => { + el.swipeHandler = { + canStart: () => true, + onStart: () => {}, + onEnd: () => {}, + }; + }); + await page.waitForChanges(); + + const box = (await outlet.boundingBox())!; + + await dragElementBy(outlet, page, 60, 0, box.x + 5, box.y + box.height / 2, false); + await expect(outlet).toHaveCSS('pointer-events', 'none'); + + await outlet.evaluate((el) => document.body.appendChild(el)); + + await expect(outlet).not.toHaveCSS('pointer-events', 'none'); + await page.mouse.up(); + }); + }); +}); diff --git a/core/src/utils/transition/index.ts b/core/src/utils/transition/index.ts index e74e7318c82..e9d6598a6e9 100644 --- a/core/src/utils/transition/index.ts +++ b/core/src/utils/transition/index.ts @@ -282,6 +282,19 @@ export const setPageHidden = (el: HTMLElement, hidden: boolean) => { } }; +/** + * Blocks clicks on `el` during a swipe to go back without animations, + * so releasing the pointer doesn't click whatever is under it. Returns + * a function that unblocks it after the release click. + */ +export const blockSwipeClicks = (el: HTMLElement) => { + el.style.setProperty('pointer-events', 'none'); + + return () => { + setTimeout(() => el.style.removeProperty('pointer-events')); + }; +}; + const setZIndex = ( enteringEl: HTMLElement | undefined, leavingEl: HTMLElement | undefined, diff --git a/packages/angular/test/base/e2e/src/standalone/swipe-animated-disabled.spec.ts b/packages/angular/test/base/e2e/src/standalone/swipe-animated-disabled.spec.ts new file mode 100644 index 00000000000..dd2365ae92b --- /dev/null +++ b/packages/angular/test/base/e2e/src/standalone/swipe-animated-disabled.spec.ts @@ -0,0 +1,123 @@ +import { expect, test, type Page } from '@playwright/test'; +import { ionSwipeToGoBack } from '../../utils/drag-utils'; +import { ionPageVisible, ionPageHidden, ionPageDoesNotExist } from '../../utils/test-utils'; + +/** + * Starts a swipe to go back and lets go partway across, so the user + * should stay on the current page. + */ +const abandonSwipeBack = async (page: Page) => { + const viewport = page.viewportSize()!; + const y = viewport.height / 2; + + await page.mouse.move(5, y); + await page.mouse.down(); + await page.mouse.move(35, y, { steps: 5 }); + await page.mouse.move(65, y, { steps: 5 }); + + // A quick drag is released with enough velocity to complete the swipe, so slow down before letting go. + for (let i = 1; i <= 3; i++) { + await page.mouse.move(65 + i, y); + await page.waitForTimeout(50); + } + + await page.mouse.up(); + + // Let a back transition started by the swipe settle before asserting. + await page.waitForTimeout(500); +}; + +test.describe('Swipe Animated Disabled', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/standalone/swipe-animated-disabled?ionic:mode=ios'); + }); + + test('should not fire enter lifecycle events on the previous page when an abandoned swipe starts on a non-animated outlet', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + await expect(page.locator('#ionViewWillEnter')).toHaveText('1'); + await expect(page.locator('#ionViewDidEnter')).toHaveText('1'); + + await page.locator('#swipe-animated-disabled-details').click(); + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + + await abandonSwipeBack(page); + + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + await expect(page.locator('#ionViewWillEnter')).toHaveText('1'); + await expect(page.locator('#ionViewDidEnter')).toHaveText('1'); + }); + + test('should go back when a swipe completes on a non-animated outlet', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + + await page.locator('#swipe-animated-disabled-details').click(); + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + + await ionSwipeToGoBack(page, true); + + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + await ionPageDoesNotExist(page, 'app-swipe-animated-disabled-details'); + await expect(page.locator('#ionViewWillEnter')).toHaveText('2'); + await expect(page.locator('#ionViewDidEnter')).toHaveText('2'); + }); + + test('should swipe back once the outlet becomes animated', async ({ page }) => { + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + + await page.locator('#toggle-animated').click(); + await expect(page.locator('#animated-value')).toHaveText('true'); + + await page.locator('#swipe-animated-disabled-details').click(); + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + + await ionSwipeToGoBack(page, true); + + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + await ionPageDoesNotExist(page, 'app-swipe-animated-disabled-details'); + }); +}); + +test.describe('Swipe Animated Disabled: global config', () => { + test.beforeEach(async ({ page }) => { + // Testing mode sets the global `animated` config to false. + await page.goto('/standalone/swipe-animated-disabled?ionic:mode=ios&ionic:_testing=true'); + }); + + test('should not fire enter lifecycle events on the previous page when an abandoned swipe starts with animations disabled globally', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + + // Animate the outlet so the global config is the only thing disabling animations. + await page.locator('#toggle-animated').click(); + await expect(page.locator('#animated-value')).toHaveText('true'); + + await page.locator('#swipe-animated-disabled-details').click(); + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + + await abandonSwipeBack(page); + + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + await expect(page.locator('#ionViewWillEnter')).toHaveText('1'); + await expect(page.locator('#ionViewDidEnter')).toHaveText('1'); + }); +}); diff --git a/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts b/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts index 337b57b8149..3d2933aa952 100644 --- a/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts +++ b/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts @@ -84,6 +84,14 @@ export const routes: Routes = [ { path: 'details', loadComponent: () => import('../swipe-gesture-disabled/swipe-gesture-disabled-details.component').then(c => c.SwipeGestureDisabledDetailsComponent) } ] }, + { + path: 'swipe-animated-disabled', + loadComponent: () => import('../swipe-animated-disabled/swipe-animated-disabled.component').then(c => c.SwipeAnimatedDisabledComponent), + children: [ + { path: '', loadComponent: () => import('../swipe-animated-disabled/swipe-animated-disabled-main.component').then(c => c.SwipeAnimatedDisabledMainComponent) }, + { path: 'details', loadComponent: () => import('../swipe-animated-disabled/swipe-animated-disabled-details.component').then(c => c.SwipeAnimatedDisabledDetailsComponent) } + ] + }, { path: 'validation', children: [ diff --git a/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html b/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html index 0adb4664e58..4a285b9950c 100644 --- a/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html +++ b/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html @@ -99,6 +99,11 @@ Swipe Gesture Disabled Test + + + Swipe Animated Disabled Test + + Tabs Search Params Test diff --git a/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-details.component.ts b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-details.component.ts new file mode 100644 index 00000000000..668487e26eb --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-details.component.ts @@ -0,0 +1,14 @@ +import { Component } from '@angular/core'; +import { IonContent } from '@ionic/angular'; + +@Component({ + selector: 'app-swipe-animated-disabled-details', + template: ` + +
Details (animated disabled)
+
+ `, + standalone: true, + imports: [IonContent] +}) +export class SwipeAnimatedDisabledDetailsComponent {} diff --git a/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-main.component.ts b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-main.component.ts new file mode 100644 index 00000000000..06d030fb709 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-main.component.ts @@ -0,0 +1,30 @@ +import { Component, signal } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { IonContent, IonItem, IonLabel, IonRouterLink } from '@ionic/angular'; + +@Component({ + selector: 'app-swipe-animated-disabled-main', + template: ` + + + Details + +

ionViewWillEnter: {{ willEnter() }}

+

ionViewDidEnter: {{ didEnter() }}

+
+ `, + standalone: true, + imports: [IonContent, IonItem, IonLabel, IonRouterLink, RouterModule] +}) +export class SwipeAnimatedDisabledMainComponent { + willEnter = signal(0); + didEnter = signal(0); + + ionViewWillEnter() { + this.willEnter.update((n) => n + 1); + } + + ionViewDidEnter() { + this.didEnter.update((n) => n + 1); + } +} diff --git a/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled.component.ts b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled.component.ts new file mode 100644 index 00000000000..1c00d0444bb --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled.component.ts @@ -0,0 +1,29 @@ +import { Component, signal } from '@angular/core'; +import { IonBackButton, IonButton, IonButtons, IonContent, IonHeader, IonRouterOutlet, IonTitle, IonToolbar } from '@ionic/angular'; + +@Component({ + selector: 'app-swipe-animated-disabled', + template: ` + + + + + + Swipe Animated Disabled + + + animated: {{ animated() }} + + + + + + + + `, + standalone: true, + imports: [IonBackButton, IonButton, IonButtons, IonContent, IonHeader, IonRouterOutlet, IonTitle, IonToolbar] +}) +export class SwipeAnimatedDisabledComponent { + animated = signal(false); +} diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index 6848c4941bb..456f14f2b8c 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -1460,7 +1460,7 @@ export class StackManager extends React.PureComponent { }; const onStart = async () => { - // Core only calls onEnd when a progress animation exists, so with animations off a + // Core skips onEnd when an animated swipe ends without a progress animation, so a // previous gesture can leave its mark behind. Drop any stale one before marking. this.clearSwipeRevealedView(); @@ -1494,9 +1494,9 @@ 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. + // Without a leaving view there is no progress animation, and core skips + // swipeHandler.onEnd for an animated swipe without one, so nothing would clear the + // mark until the next transition. markSwipeRevealed(enteringViewItem); this.swipeRevealedViewItem = enteringViewItem; diff --git a/packages/react-router/test/base/tests/e2e/playwright/swipe-to-go-back.spec.ts b/packages/react-router/test/base/tests/e2e/playwright/swipe-to-go-back.spec.ts index f9539df0e94..8817177ce4d 100644 --- a/packages/react-router/test/base/tests/e2e/playwright/swipe-to-go-back.spec.ts +++ b/packages/react-router/test/base/tests/e2e/playwright/swipe-to-go-back.spec.ts @@ -1,13 +1,12 @@ import { test, expect } from '@playwright/test'; -import { ionPageVisible, ionPageHidden, ionPageDoesNotExist, ionNav } from './utils/test-utils'; +import { ionPageVisible, ionPageHidden, ionPageDoesNotExist, ionNav, withTestingMode } from './utils/test-utils'; import { ionSwipeToGoBack } from './utils/drag-utils'; const IOS_MODE = 'ionic:mode=ios'; test.describe('Swipe To Go Back', () => { /* - This spec tests that swipe to go back works. - Animations must be enabled (no ionic:_testing=true) for gesture tests. + This spec tests that swipe to go back works with animations enabled (no ionic:_testing=true). */ test('should swipe and abort', async ({ page }) => { @@ -218,3 +217,46 @@ test.describe('Swipe To Go Back', () => { await ionPageHidden(page, 'disabled-main'); }); }); + +test.describe('Swipe To Go Back without animations', () => { + /* + Testing mode disables animations, so these cover swipe to go back without them. + */ + + test('should swipe and abort', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await page.goto(withTestingMode(`/swipe-to-go-back?${IOS_MODE}`)); + await ionPageVisible(page, 'main'); + + await ionNav(page, 'ion-item', 'Details'); + await ionPageVisible(page, 'details'); + await ionPageHidden(page, 'main'); + + await ionSwipeToGoBack(page, false, 'ion-router-outlet#swipe-to-go-back'); + await ionPageVisible(page, 'details'); + await ionPageHidden(page, 'main'); + await expect(page).toHaveURL(/\/swipe-to-go-back\/details/); + }); + + test('should swipe and go back', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await page.goto(withTestingMode(`/swipe-to-go-back?${IOS_MODE}`)); + await ionPageVisible(page, 'main'); + + await ionNav(page, 'ion-item', 'Details'); + await ionPageVisible(page, 'details'); + await ionPageHidden(page, 'main'); + + await ionSwipeToGoBack(page, true, 'ion-router-outlet#swipe-to-go-back'); + await ionPageVisible(page, 'main'); + await expect(page).toHaveURL(/\/swipe-to-go-back(\?|$)/); + }); +}); diff --git a/packages/vue/test/base/tests/e2e/playwright/swipe-back.spec.ts b/packages/vue/test/base/tests/e2e/playwright/swipe-back.spec.ts new file mode 100644 index 00000000000..3c78fa49149 --- /dev/null +++ b/packages/vue/test/base/tests/e2e/playwright/swipe-back.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from './utils/test-base'; +import { ionPageVisible, ionPageHidden, ionSwipeToGoBack } from './utils/test-utils'; + +/** + * The test fixture disables animations, so these cover swipe to go back without them. + */ +test.describe('Swipe to go back without animations', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/?ionic:mode=ios'); + await ionPageVisible(page, 'home'); + + await page.locator('ion-item#routing').click({ force: true }); + await ionPageVisible(page, 'routing'); + await ionPageHidden(page, 'home'); + }); + + test('should go back when the swipe completes', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await ionSwipeToGoBack(page, true); + + await ionPageVisible(page, 'home'); + await expect(page).toHaveURL(/\/\?/); + }); + + test('should stay on the current page when the swipe is abandoned', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await ionSwipeToGoBack(page, false); + + await ionPageVisible(page, 'routing'); + await ionPageHidden(page, 'home'); + await expect(page).toHaveURL(/\/routing/); + }); +});