Skip to content

Commit 584e9d3

Browse files
fix(overlays): prevent overlays from getting stuck open (#28069)
Issue number: resolves #27200 --------- <!-- 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. --> A bug occurs when you click twice quickly to open an overlay with a small timeout. In some cases, the overlay will present, dismiss, present, then not dismiss the second time, getting stuck open. You can reproduce manually this by grabbing the test HTML included in this PR and putting it in a branch that doesn't include a fix. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> - When an overlay with a short timeout is triggered twice quickly, it will open-close-open-close. - The behavior is the same for all overlay components ## 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. --> Relevant links: * #27200 * https://ionic-cloud.atlassian.net/browse/FW-4374 * https://ionic-cloud.atlassian.net/browse/FW-4053 I'm not sure how to write an automated test for this bug due to the short timeout required. You can manually test the fix in [this Stackblitz](https://stackblitz.com/edit/g1kjci?file=package.json) by changing the Ionic version between 7.3.1 and 7.3.2-dev.11693262117.17edbf6d --------- Co-authored-by: Liam DeBeasi <[email protected]>
1 parent e1fdbb3 commit 584e9d3

File tree

9 files changed

+120
-162
lines changed

9 files changed

+120
-162
lines changed

core/src/components/action-sheet/action-sheet.tsx

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Watch, Component, Element, Event, Host, Method, Prop, h, readTask } fro
33
import type { Gesture } from '@utils/gesture';
44
import { createButtonActiveGesture } from '@utils/gesture/button-active';
55
import { raf } from '@utils/helpers';
6+
import { createLockController } from '@utils/lock-controller';
67
import {
78
BACKDROP,
89
createDelegateController,
@@ -40,8 +41,8 @@ import { mdLeaveAnimation } from './animations/md.leave';
4041
})
4142
export class ActionSheet implements ComponentInterface, OverlayInterface {
4243
private readonly delegateController = createDelegateController(this);
44+
private readonly lockController = createLockController();
4345
private readonly triggerController = createTriggerController();
44-
private currentTransition?: Promise<any>;
4546
private wrapperEl?: HTMLElement;
4647
private groupEl?: HTMLElement;
4748
private gesture?: Gesture;
@@ -198,25 +199,13 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
198199
*/
199200
@Method()
200201
async present(): Promise<void> {
201-
/**
202-
* When using an inline action sheet
203-
* and dismissing a action sheet it is possible to
204-
* quickly present the action sheet while it is
205-
* dismissing. We need to await any current
206-
* transition to allow the dismiss to finish
207-
* before presenting again.
208-
*/
209-
if (this.currentTransition !== undefined) {
210-
await this.currentTransition;
211-
}
202+
const unlock = await this.lockController.lock();
212203

213204
await this.delegateController.attachViewToDom();
214205

215-
this.currentTransition = present(this, 'actionSheetEnter', iosEnterAnimation, mdEnterAnimation);
206+
await present(this, 'actionSheetEnter', iosEnterAnimation, mdEnterAnimation);
216207

217-
await this.currentTransition;
218-
219-
this.currentTransition = undefined;
208+
unlock();
220209
}
221210

222211
/**
@@ -230,13 +219,16 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
230219
*/
231220
@Method()
232221
async dismiss(data?: any, role?: string): Promise<boolean> {
233-
this.currentTransition = dismiss(this, data, role, 'actionSheetLeave', iosLeaveAnimation, mdLeaveAnimation);
234-
const dismissed = await this.currentTransition;
222+
const unlock = await this.lockController.lock();
223+
224+
const dismissed = await dismiss(this, data, role, 'actionSheetLeave', iosLeaveAnimation, mdLeaveAnimation);
235225

236226
if (dismissed) {
237227
this.delegateController.removeViewFromDom();
238228
}
239229

230+
unlock();
231+
240232
return dismissed;
241233
}
242234

core/src/components/alert/alert.tsx

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ENABLE_HTML_CONTENT_DEFAULT } from '@utils/config';
44
import type { Gesture } from '@utils/gesture';
55
import { createButtonActiveGesture } from '@utils/gesture/button-active';
66
import { raf } from '@utils/helpers';
7+
import { createLockController } from '@utils/lock-controller';
78
import {
89
createDelegateController,
910
createTriggerController,
@@ -46,6 +47,7 @@ import { mdLeaveAnimation } from './animations/md.leave';
4647
})
4748
export class Alert implements ComponentInterface, OverlayInterface {
4849
private readonly delegateController = createDelegateController(this);
50+
private readonly lockController = createLockController();
4951
private readonly triggerController = createTriggerController();
5052
private customHTMLEnabled = config.get('innerHTMLTemplatesEnabled', ENABLE_HTML_CONTENT_DEFAULT);
5153
private activeId?: string;
@@ -54,7 +56,6 @@ export class Alert implements ComponentInterface, OverlayInterface {
5456
private processedButtons: AlertButton[] = [];
5557
private wrapperEl?: HTMLElement;
5658
private gesture?: Gesture;
57-
private currentTransition?: Promise<any>;
5859

5960
presented = false;
6061
lastFocus?: HTMLElement;
@@ -373,23 +374,13 @@ export class Alert implements ComponentInterface, OverlayInterface {
373374
*/
374375
@Method()
375376
async present(): Promise<void> {
376-
/**
377-
* When using an inline alert
378-
* and dismissing an alert it is possible to
379-
* quickly present the alert while it is
380-
* dismissing. We need to await any current
381-
* transition to allow the dismiss to finish
382-
* before presenting again.
383-
*/
384-
if (this.currentTransition !== undefined) {
385-
await this.currentTransition;
386-
}
377+
const unlock = await this.lockController.lock();
387378

388379
await this.delegateController.attachViewToDom();
389380

390-
this.currentTransition = present(this, 'alertEnter', iosEnterAnimation, mdEnterAnimation);
391-
await this.currentTransition;
392-
this.currentTransition = undefined;
381+
await present(this, 'alertEnter', iosEnterAnimation, mdEnterAnimation);
382+
383+
unlock();
393384
}
394385

395386
/**
@@ -403,13 +394,16 @@ export class Alert implements ComponentInterface, OverlayInterface {
403394
*/
404395
@Method()
405396
async dismiss(data?: any, role?: string): Promise<boolean> {
406-
this.currentTransition = dismiss(this, data, role, 'alertLeave', iosLeaveAnimation, mdLeaveAnimation);
407-
const dismissed = await this.currentTransition;
397+
const unlock = await this.lockController.lock();
398+
399+
const dismissed = await dismiss(this, data, role, 'alertLeave', iosLeaveAnimation, mdLeaveAnimation);
408400

409401
if (dismissed) {
410402
this.delegateController.removeViewFromDom();
411403
}
412404

405+
unlock();
406+
413407
return dismissed;
414408
}
415409

core/src/components/loading/loading.tsx

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core';
22
import { Watch, Component, Element, Event, Host, Method, Prop, h } from '@stencil/core';
33
import { ENABLE_HTML_CONTENT_DEFAULT } from '@utils/config';
44
import { raf } from '@utils/helpers';
5+
import { createLockController } from '@utils/lock-controller';
56
import {
67
BACKDROP,
78
dismiss,
@@ -42,10 +43,10 @@ import { mdLeaveAnimation } from './animations/md.leave';
4243
})
4344
export class Loading implements ComponentInterface, OverlayInterface {
4445
private readonly delegateController = createDelegateController(this);
46+
private readonly lockController = createLockController();
4547
private readonly triggerController = createTriggerController();
4648
private customHTMLEnabled = config.get('innerHTMLTemplatesEnabled', ENABLE_HTML_CONTENT_DEFAULT);
4749
private durationTimeout?: ReturnType<typeof setTimeout>;
48-
private currentTransition?: Promise<any>;
4950

5051
presented = false;
5152
lastFocus?: HTMLElement;
@@ -235,29 +236,17 @@ export class Loading implements ComponentInterface, OverlayInterface {
235236
*/
236237
@Method()
237238
async present(): Promise<void> {
238-
/**
239-
* When using an inline loading indicator
240-
* and dismissing a loading indicator it is possible to
241-
* quickly present the loading indicator while it is
242-
* dismissing. We need to await any current
243-
* transition to allow the dismiss to finish
244-
* before presenting again.
245-
*/
246-
if (this.currentTransition !== undefined) {
247-
await this.currentTransition;
248-
}
239+
const unlock = await this.lockController.lock();
249240

250241
await this.delegateController.attachViewToDom();
251242

252-
this.currentTransition = present(this, 'loadingEnter', iosEnterAnimation, mdEnterAnimation);
253-
254-
await this.currentTransition;
243+
await present(this, 'loadingEnter', iosEnterAnimation, mdEnterAnimation);
255244

256245
if (this.duration > 0) {
257246
this.durationTimeout = setTimeout(() => this.dismiss(), this.duration + 10);
258247
}
259248

260-
this.currentTransition = undefined;
249+
unlock();
261250
}
262251

263252
/**
@@ -271,17 +260,19 @@ export class Loading implements ComponentInterface, OverlayInterface {
271260
*/
272261
@Method()
273262
async dismiss(data?: any, role?: string): Promise<boolean> {
263+
const unlock = await this.lockController.lock();
264+
274265
if (this.durationTimeout) {
275266
clearTimeout(this.durationTimeout);
276267
}
277-
this.currentTransition = dismiss(this, data, role, 'loadingLeave', iosLeaveAnimation, mdLeaveAnimation);
278-
279-
const dismissed = await this.currentTransition;
268+
const dismissed = await dismiss(this, data, role, 'loadingLeave', iosLeaveAnimation, mdLeaveAnimation);
280269

281270
if (dismissed) {
282271
this.delegateController.removeViewFromDom();
283272
}
284273

274+
unlock();
275+
285276
return dismissed;
286277
}
287278

core/src/components/modal/modal.tsx

Lines changed: 19 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { findIonContent, printIonContentErrorMsg } from '@utils/content';
44
import { CoreDelegate, attachComponent, detachComponent } from '@utils/framework-delegate';
55
import { raf, inheritAttributes, hasLazyBuild } from '@utils/helpers';
66
import type { Attributes } from '@utils/helpers';
7+
import { createLockController } from '@utils/lock-controller';
78
import { printIonWarning } from '@utils/logging';
89
import { Style as StatusBarStyle, StatusBar } from '@utils/native/status-bar';
910
import {
@@ -64,10 +65,10 @@ import { setCardStatusBarDark, setCardStatusBarDefault } from './utils';
6465
shadow: true,
6566
})
6667
export class Modal implements ComponentInterface, OverlayInterface {
68+
private readonly lockController = createLockController();
6769
private readonly triggerController = createTriggerController();
6870
private gesture?: Gesture;
6971
private coreDelegate: FrameworkDelegate = CoreDelegate();
70-
private currentTransition?: Promise<any>;
7172
private sheetTransition?: Promise<any>;
7273
private isSheetModal = false;
7374
private currentBreakpoint?: number;
@@ -422,24 +423,15 @@ export class Modal implements ComponentInterface, OverlayInterface {
422423
*/
423424
@Method()
424425
async present(): Promise<void> {
426+
const unlock = await this.lockController.lock();
427+
425428
if (this.presented) {
429+
unlock();
426430
return;
427431
}
428432

429433
const { presentingElement, el } = this;
430434

431-
/**
432-
* When using an inline modal
433-
* and dismissing a modal it is possible to
434-
* quickly present the modal while it is
435-
* dismissing. We need to await any current
436-
* transition to allow the dismiss to finish
437-
* before presenting again.
438-
*/
439-
if (this.currentTransition !== undefined) {
440-
await this.currentTransition;
441-
}
442-
443435
/**
444436
* If the modal is presented multiple times (inline modals), we
445437
* need to reset the current breakpoint to the initial breakpoint.
@@ -481,7 +473,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
481473

482474
writeTask(() => this.el.classList.add('show-modal'));
483475

484-
this.currentTransition = present<ModalPresentOptions>(this, 'modalEnter', iosEnterAnimation, mdEnterAnimation, {
476+
await present<ModalPresentOptions>(this, 'modalEnter', iosEnterAnimation, mdEnterAnimation, {
485477
presentingEl: presentingElement,
486478
currentBreakpoint: this.initialBreakpoint,
487479
backdropBreakpoint: this.backdropBreakpoint,
@@ -532,15 +524,13 @@ export class Modal implements ComponentInterface, OverlayInterface {
532524
setCardStatusBarDark();
533525
}
534526

535-
await this.currentTransition;
536-
537527
if (this.isSheetModal) {
538528
this.initSheetGesture();
539529
} else if (hasCardModal) {
540530
this.initSwipeToClose();
541531
}
542532

543-
this.currentTransition = undefined;
533+
unlock();
544534
}
545535

546536
private initSwipeToClose() {
@@ -656,12 +646,20 @@ export class Modal implements ComponentInterface, OverlayInterface {
656646
return false;
657647
}
658648

649+
/**
650+
* Because the canDismiss check below is async,
651+
* we need to claim a lock before the check happens,
652+
* in case the dismiss transition does run.
653+
*/
654+
const unlock = await this.lockController.lock();
655+
659656
/**
660657
* If a canDismiss handler is responsible
661658
* for calling the dismiss method, we should
662659
* not run the canDismiss check again.
663660
*/
664661
if (role !== 'handler' && !(await this.checkCanDismiss(data, role))) {
662+
unlock();
665663
return false;
666664
}
667665

@@ -683,21 +681,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
683681
this.keyboardOpenCallback = undefined;
684682
}
685683

686-
/**
687-
* When using an inline modal
688-
* and presenting a modal it is possible to
689-
* quickly dismiss the modal while it is
690-
* presenting. We need to await any current
691-
* transition to allow the present to finish
692-
* before dismissing again.
693-
*/
694-
if (this.currentTransition !== undefined) {
695-
await this.currentTransition;
696-
}
697-
698684
const enteringAnimation = activeAnimations.get(this) || [];
699685

700-
this.currentTransition = dismiss<ModalDismissOptions>(
686+
const dismissed = await dismiss<ModalDismissOptions>(
701687
this,
702688
data,
703689
role,
@@ -711,8 +697,6 @@ export class Modal implements ComponentInterface, OverlayInterface {
711697
}
712698
);
713699

714-
const dismissed = await this.currentTransition;
715-
716700
if (dismissed) {
717701
const { delegate } = this.getDelegate();
718702
await detachComponent(delegate, this.usersElement);
@@ -729,8 +713,10 @@ export class Modal implements ComponentInterface, OverlayInterface {
729713
enteringAnimation.forEach((ani) => ani.destroy());
730714
}
731715
this.currentBreakpoint = undefined;
732-
this.currentTransition = undefined;
733716
this.animation = undefined;
717+
718+
unlock();
719+
734720
return dismissed;
735721
}
736722

0 commit comments

Comments
 (0)