Skip to content

Commit a169044

Browse files
authored
fix(menu): do not error if disabled or swipeGesture is changed mid-animation (#28268)
Issue number: resolves #20092, resolves #19676, resolves #19000 --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying. --> Menu is currently throwing errors because it expects no animations to be running when any state changes happen (such as changing `disabled` or `swipeGesture`). For example, if you set `swipeGesture="false"` mid-gesture then the menu will error. Alternatively, if you set `disabled="true"` mid-open animation then the menu will error also. This is undesirable because it can cause visual flickering and other undesirable behaviors as noted in the linked threads. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> - Any in-progress animation is cancelled if the state updates such that the animation is no longer relevant (i.e. `disabled` is set to `true` while the menu is opening) - Removed relevant assertions - Added tests ## Does this introduce a breaking change? - [ ] Yes - [x] No <!-- If this introduces a breaking change, please describe the impact and migration path for existing applications below. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> Dev build: `7.4.3-dev.11696264821.1755dd6a`
1 parent e6031fb commit a169044

File tree

3 files changed

+163
-25
lines changed

3 files changed

+163
-25
lines changed

core/src/components/menu/menu.tsx

Lines changed: 56 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ export class Menu implements ComponentInterface, MenuI {
4040
private blocker = GESTURE_CONTROLLER.createBlocker({ disableScroll: true });
4141
private didLoad = false;
4242

43+
/**
44+
* Flag used to determine if an open/close
45+
* operation was cancelled. For example, if
46+
* an app calls "menu.open" then disables the menu
47+
* part way through the animation, then this would
48+
* be considered a cancelled operation.
49+
*/
50+
private operationCancelled = false;
51+
4352
isAnimating = false;
4453
width!: number;
4554
_isOpen = false;
@@ -432,6 +441,17 @@ export class Menu implements ComponentInterface, MenuI {
432441

433442
await this.loadAnimation();
434443
await this.startAnimation(shouldOpen, animated);
444+
445+
/**
446+
* If the animation was cancelled then
447+
* return false because the operation
448+
* did not succeed.
449+
*/
450+
if (this.operationCancelled) {
451+
this.operationCancelled = false;
452+
return false;
453+
}
454+
435455
this.afterAnimation(shouldOpen);
436456

437457
return true;
@@ -472,18 +492,24 @@ export class Menu implements ComponentInterface, MenuI {
472492
const easingReverse = mode === 'ios' ? iosEasingReverse : mdEasingReverse;
473493
const ani = (this.animation as Animation)!
474494
.direction(isReversed ? 'reverse' : 'normal')
475-
.easing(isReversed ? easingReverse : easing)
476-
.onFinish(() => {
477-
if (ani.getDirection() === 'reverse') {
478-
ani.direction('normal');
479-
}
480-
});
495+
.easing(isReversed ? easingReverse : easing);
481496

482497
if (animated) {
483498
await ani.play();
484499
} else {
485500
ani.play({ sync: true });
486501
}
502+
503+
/**
504+
* We run this after the play invocation
505+
* instead of using ani.onFinish so that
506+
* multiple onFinish callbacks do not get
507+
* run if an animation is played, stopped,
508+
* and then played again.
509+
*/
510+
if (ani.getDirection() === 'reverse') {
511+
ani.direction('normal');
512+
}
487513
}
488514

489515
private _isActive() {
@@ -643,8 +669,6 @@ export class Menu implements ComponentInterface, MenuI {
643669
}
644670

645671
private afterAnimation(isOpen: boolean) {
646-
assert(this.isAnimating, '_before() should be called while animating');
647-
648672
// keep opening/closing the menu disabled for a touch more yet
649673
// only add listeners/css if it's enabled and isOpen
650674
// and only remove listeners/css if it's not open
@@ -713,10 +737,30 @@ export class Menu implements ComponentInterface, MenuI {
713737
this.gesture.enable(isActive && this.swipeGesture);
714738
}
715739

716-
// Close menu immediately
717-
if (!isActive && this._isOpen) {
718-
// close if this menu is open, and should not be enabled
719-
this.forceClosing();
740+
/**
741+
* If the menu is disabled but it is still open
742+
* then we should close the menu immediately.
743+
* Additionally, if the menu is in the process
744+
* of animating {open, close} and the menu is disabled
745+
* then it should still be closed immediately.
746+
*/
747+
if (!isActive) {
748+
/**
749+
* It is possible to disable the menu while
750+
* it is mid-animation. When this happens, we
751+
* need to set the operationCancelled flag
752+
* so that this._setOpen knows to return false
753+
* and not run the "afterAnimation" callback.
754+
*/
755+
if (this.isAnimating) {
756+
this.operationCancelled = true;
757+
}
758+
759+
/**
760+
* If the menu is disabled then we should
761+
* forcibly close the menu even if it is open.
762+
*/
763+
this.afterAnimation(false);
720764
}
721765

722766
if (doc?.contains(this.el)) {
@@ -730,19 +774,6 @@ export class Menu implements ComponentInterface, MenuI {
730774
menuController._setActiveMenu(this);
731775
}
732776
}
733-
734-
assert(!this.isAnimating, 'can not be animating');
735-
}
736-
737-
private forceClosing() {
738-
assert(this._isOpen, 'menu cannot be closed');
739-
740-
this.isAnimating = true;
741-
742-
const ani = (this.animation as Animation)!.direction('reverse');
743-
ani.play({ sync: true });
744-
745-
this.afterAnimation(false);
746777
}
747778

748779
render() {
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<!DOCTYPE html>
2+
<html lang="en" dir="ltr">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Menu - Disable</title>
6+
<meta
7+
name="viewport"
8+
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
9+
/>
10+
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
11+
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
12+
<script src="../../../../../scripts/testing/scripts.js"></script>
13+
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
14+
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
15+
</head>
16+
17+
<body>
18+
<ion-app>
19+
<ion-menu side="start" id="start-menu" menu-id="start-menu" content-id="main">
20+
<ion-header>
21+
<ion-toolbar color="primary">
22+
<ion-title>Menu</ion-title>
23+
</ion-toolbar>
24+
</ion-header>
25+
<ion-content class="ion-padding"> Menu Content </ion-content>
26+
</ion-menu>
27+
28+
<div class="ion-page" id="main">
29+
<ion-header>
30+
<ion-toolbar>
31+
<ion-buttons slot="start">
32+
<ion-menu-button></ion-menu-button>
33+
</ion-buttons>
34+
<ion-title>Menu - Disable</ion-title>
35+
</ion-toolbar>
36+
</ion-header>
37+
<ion-content class="ion-padding">Content</ion-content>
38+
</div>
39+
</ion-app>
40+
</body>
41+
</html>
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { expect } from '@playwright/test';
2+
import { configs, test } from '@utils/test/playwright';
3+
4+
/**
5+
* This behavior does not vary across modes/directions
6+
*/
7+
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
8+
test.describe(title('menu: disable'), () => {
9+
test.beforeEach(async ({ page }) => {
10+
await page.goto(`/src/components/menu/test/disable`, config);
11+
});
12+
13+
test('should disable when menu is fully open', async ({ page }) => {
14+
const logs: string[] = [];
15+
16+
page.on('console', (msg) => {
17+
if (msg.type() === 'error') {
18+
logs.push(msg.text());
19+
}
20+
});
21+
22+
const menu = page.locator('ion-menu');
23+
24+
// Should be visible on initial presentation
25+
await menu.evaluate((el: HTMLIonMenuElement) => el.open());
26+
await expect(menu).toBeVisible();
27+
28+
// Disabling menu should hide it
29+
await menu.evaluate((el: HTMLIonMenuElement) => (el.disabled = true));
30+
await expect(menu).toBeHidden();
31+
32+
// Re-enabling menu and opening it show make it visible
33+
await menu.evaluate((el: HTMLIonMenuElement) => (el.disabled = false));
34+
await menu.evaluate((el: HTMLIonMenuElement) => el.open());
35+
await expect(menu).toBeVisible();
36+
37+
expect(logs.length).toBe(0);
38+
});
39+
40+
test('should disable when menu is animating', async ({ page }) => {
41+
const logs: string[] = [];
42+
43+
page.on('console', (msg) => {
44+
if (msg.type() === 'error') {
45+
logs.push(msg.text());
46+
}
47+
});
48+
49+
const menu = page.locator('ion-menu');
50+
51+
// Opening and quickly disabling menu should hide it
52+
menu.evaluate((el: HTMLIonMenuElement) => {
53+
el.open();
54+
setTimeout(() => (el.disabled = true), 0);
55+
});
56+
await expect(menu).toBeHidden();
57+
58+
// Re-enabling menu and opening it show make it visible
59+
await menu.evaluate((el: HTMLIonMenuElement) => (el.disabled = false));
60+
await menu.evaluate((el: HTMLIonMenuElement) => el.open());
61+
await expect(menu).toBeVisible();
62+
63+
expect(logs.length).toBe(0);
64+
});
65+
});
66+
});

0 commit comments

Comments
 (0)