From 894e4a7b624e3847903c87d920edeb9e36e971bf Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 15 Mar 2022 17:33:20 +0100 Subject: [PATCH] MOBILE-3833 core: Implement cancellable promise --- src/core/classes/cancellable-promise.ts | 56 +++++++++++ src/core/classes/promise.ts | 54 +++++++++++ src/core/classes/promised-value.ts | 92 ++++++++----------- src/core/directives/collapsible-footer.ts | 12 +-- src/core/directives/collapsible-item.ts | 12 ++- src/core/directives/format-text.ts | 13 +-- src/core/directives/on-appear.ts | 11 ++- .../rich-text-editor/rich-text-editor.ts | 14 +-- src/core/services/utils/dom.ts | 56 ++++++----- src/core/singletons/events.ts | 15 --- 10 files changed, 208 insertions(+), 127 deletions(-) create mode 100644 src/core/classes/cancellable-promise.ts create mode 100644 src/core/classes/promise.ts diff --git a/src/core/classes/cancellable-promise.ts b/src/core/classes/cancellable-promise.ts new file mode 100644 index 000000000..fb3f0ad6c --- /dev/null +++ b/src/core/classes/cancellable-promise.ts @@ -0,0 +1,56 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CorePromise } from '@classes/promise'; + +/** + * Promise whose execution can be cancelled. + */ +export class CoreCancellablePromise extends CorePromise { + + /** + * Create a new resolved promise. + * + * @returns Resolved promise. + */ + static resolve(): CoreCancellablePromise; + static resolve(result: T): CoreCancellablePromise; + static resolve(result?: T): CoreCancellablePromise { + return new this(resolve => result ? resolve(result) : (resolve as () => void)(), () => { + // Nothing to do here. + }); + } + + protected cancelPromise: () => void; + + constructor( + executor: (resolve: (value: T | PromiseLike) => void, reject: (reason?: Error) => void) => void, + cancelPromise: () => void, + ) { + super(new Promise(executor)); + + this.cancelPromise = cancelPromise; + } + + /** + * Cancel promise. + * + * After this method is called, the promise will remain unresolved forever. Make sure that after calling + * this method there aren't any references to this object, or it could cause memory leaks. + */ + cancel(): void { + this.cancelPromise(); + } + +} diff --git a/src/core/classes/promise.ts b/src/core/classes/promise.ts new file mode 100644 index 000000000..7fe5897b5 --- /dev/null +++ b/src/core/classes/promise.ts @@ -0,0 +1,54 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Base class to use for implementing custom Promises. + */ +export abstract class CorePromise implements Promise { + + protected nativePromise: Promise; + + constructor(nativePromise: Promise) { + this.nativePromise = nativePromise; + } + + [Symbol.toStringTag]: string; + + /** + * @inheritdoc + */ + then( + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, + onRejected?: ((reason: Error) => TResult2 | PromiseLike) | undefined | null, + ): Promise { + return this.nativePromise.then(onFulfilled, onRejected); + } + + /** + * @inheritdoc + */ + catch( + onRejected?: ((reason: Error) => TResult | PromiseLike) | undefined | null, + ): Promise { + return this.nativePromise.catch(onRejected); + } + + /** + * @inheritdoc + */ + finally(onFinally?: (() => void) | null): Promise { + return this.nativePromise.finally(onFinally); + } + +} diff --git a/src/core/classes/promised-value.ts b/src/core/classes/promised-value.ts index 16051be37..c4de39afa 100644 --- a/src/core/classes/promised-value.ts +++ b/src/core/classes/promised-value.ts @@ -12,10 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { CorePromise } from '@classes/promise'; + /** * Promise wrapper to expose result synchronously. */ -export class CorePromisedValue implements Promise { +export class CorePromisedValue extends CorePromise { /** * Wrap an existing promise. @@ -33,20 +35,28 @@ export class CorePromisedValue implements Promise { return promisedValue; } - private _resolvedValue?: T; - private _rejectedReason?: Error; - declare private promise: Promise; - declare private _resolve: (result: T) => void; - declare private _reject: (error?: Error) => void; + protected resolvedValue?: T; + protected rejectedReason?: Error; + protected resolvePromise!: (result: T) => void; + protected rejectPromise!: (error?: Error) => void; constructor() { - this.initPromise(); + let resolvePromise!: (result: T) => void; + let rejectPromise!: (error?: Error) => void; + + const nativePromise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + + super(nativePromise); + + this.resolvePromise = resolvePromise; + this.rejectPromise = rejectPromise; } - [Symbol.toStringTag]: string; - get value(): T | null { - return this._resolvedValue ?? null; + return this.resolvedValue ?? null; } /** @@ -55,7 +65,7 @@ export class CorePromisedValue implements Promise { * @return Whether the promise resolved successfuly. */ isResolved(): this is { value: T } { - return '_resolvedValue' in this; + return 'resolvedValue' in this; } /** @@ -64,7 +74,7 @@ export class CorePromisedValue implements Promise { * @return Whether the promise was rejected. */ isRejected(): boolean { - return '_rejectedReason' in this; + return 'rejectedReason' in this; } /** @@ -76,32 +86,6 @@ export class CorePromisedValue implements Promise { return this.isResolved() || this.isRejected(); } - /** - * @inheritdoc - */ - then( - onFulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, - onRejected?: ((reason: Error) => TResult2 | PromiseLike) | undefined | null, - ): Promise { - return this.promise.then(onFulfilled, onRejected); - } - - /** - * @inheritdoc - */ - catch( - onRejected?: ((reason: Error) => TResult | PromiseLike) | undefined | null, - ): Promise { - return this.promise.catch(onRejected); - } - - /** - * @inheritdoc - */ - finally(onFinally?: (() => void) | null): Promise { - return this.promise.finally(onFinally); - } - /** * Resolve the promise. * @@ -109,13 +93,13 @@ export class CorePromisedValue implements Promise { */ resolve(value: T): void { if (this.isSettled()) { - delete this._rejectedReason; + delete this.rejectedReason; - this.initPromise(); + this.resetNativePromise(); } - this._resolvedValue = value; - this._resolve(value); + this.resolvedValue = value; + this.resolvePromise(value); } /** @@ -125,32 +109,32 @@ export class CorePromisedValue implements Promise { */ reject(reason?: Error): void { if (this.isSettled()) { - delete this._resolvedValue; + delete this.resolvedValue; - this.initPromise(); + this.resetNativePromise(); } - this._rejectedReason = reason; - this._reject(reason); + this.rejectedReason = reason; + this.rejectPromise(reason); } /** * Reset status and value. */ reset(): void { - delete this._resolvedValue; - delete this._rejectedReason; + delete this.resolvedValue; + delete this.rejectedReason; - this.initPromise(); + this.resetNativePromise(); } /** - * Initialize the promise and the callbacks. + * Reset native promise and callbacks. */ - private initPromise(): void { - this.promise = new Promise((resolve, reject) => { - this._resolve = resolve; - this._reject = reject; + protected resetNativePromise(): void { + this.nativePromise = new Promise((resolve, reject) => { + this.resolvePromise = resolve; + this.rejectPromise = reject; }); } diff --git a/src/core/directives/collapsible-footer.ts b/src/core/directives/collapsible-footer.ts index cbd07969d..f63d1f57c 100644 --- a/src/core/directives/collapsible-footer.ts +++ b/src/core/directives/collapsible-footer.ts @@ -19,9 +19,10 @@ import { CoreUtils } from '@services/utils/utils'; import { CoreMath } from '@singletons/math'; import { CoreComponentsRegistry } from '@singletons/components-registry'; import { CoreFormatTextDirective } from './format-text'; -import { CoreEventObserver, CoreSingleTimeEventObserver } from '@singletons/events'; +import { CoreEventObserver } from '@singletons/events'; import { CoreLoadingComponent } from '@components/loading/loading'; import { CoreDomUtils } from '@services/utils/dom'; +import { CoreCancellablePromise } from '@classes/cancellable-promise'; /** * Directive to make an element fixed at the bottom collapsible when scrolling. @@ -48,7 +49,7 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy { protected contentScrollListener?: EventListener; protected endContentScrollListener?: EventListener; protected resizeListener?: CoreEventObserver; - protected domListener?: CoreSingleTimeEventObserver; + protected domPromise?: CoreCancellablePromise; constructor(el: ElementRef, protected ionContent: IonContent) { this.element = el.nativeElement; @@ -61,10 +62,9 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy { async ngOnInit(): Promise { // Only if not present or explicitly falsy it will be false. this.appearOnBottom = !CoreUtils.isFalseOrZero(this.appearOnBottom); + this.domPromise = CoreDomUtils.waitToBeInDOM(this.element); - this.domListener = CoreDomUtils.waitToBeInDOM(this.element); - await this.domListener.promise; - + await this.domPromise; await this.waitLoadingsDone(); await this.waitFormatTextsRendered(this.element); @@ -234,7 +234,7 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy { } this.resizeListener?.off(); - this.domListener?.off(); + this.domPromise?.cancel(); } } diff --git a/src/core/directives/collapsible-item.ts b/src/core/directives/collapsible-item.ts index d9ab8990e..08e987035 100644 --- a/src/core/directives/collapsible-item.ts +++ b/src/core/directives/collapsible-item.ts @@ -13,12 +13,13 @@ // limitations under the License. import { Directive, ElementRef, Input, OnDestroy, OnInit } from '@angular/core'; +import { CoreCancellablePromise } from '@classes/cancellable-promise'; import { CoreLoadingComponent } from '@components/loading/loading'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; import { Translate } from '@singletons'; import { CoreComponentsRegistry } from '@singletons/components-registry'; -import { CoreEventObserver, CoreSingleTimeEventObserver } from '@singletons/events'; +import { CoreEventObserver } from '@singletons/events'; import { CoreFormatTextDirective } from './format-text'; const defaultMaxHeight = 80; @@ -49,7 +50,7 @@ export class CoreCollapsibleItemDirective implements OnInit, OnDestroy { protected maxHeight = defaultMaxHeight; protected expandedHeight = 0; protected resizeListener?: CoreEventObserver; - protected domListener?: CoreSingleTimeEventObserver; + protected domPromise?: CoreCancellablePromise; constructor(el: ElementRef) { this.element = el.nativeElement; @@ -96,8 +97,9 @@ export class CoreCollapsibleItemDirective implements OnInit, OnDestroy { * @return Promise resolved when loadings are done. */ protected async waitLoadingsDone(): Promise { - this.domListener = CoreDomUtils.waitToBeInDOM(this.element); - await this.domListener.promise; + this.domPromise = CoreDomUtils.waitToBeInDOM(this.element); + + await this.domPromise; const page = this.element.closest('.ion-page'); @@ -242,7 +244,7 @@ export class CoreCollapsibleItemDirective implements OnInit, OnDestroy { */ ngOnDestroy(): void { this.resizeListener?.off(); - this.domListener?.off(); + this.domPromise?.cancel(); } } diff --git a/src/core/directives/format-text.ts b/src/core/directives/format-text.ts index 12dd1f0ed..42c780f76 100644 --- a/src/core/directives/format-text.ts +++ b/src/core/directives/format-text.ts @@ -42,7 +42,7 @@ import { CoreFilterHelper } from '@features/filter/services/filter-helper'; import { CoreSubscriptions } from '@singletons/subscriptions'; import { CoreComponentsRegistry } from '@singletons/components-registry'; import { CoreCollapsibleItemDirective } from './collapsible-item'; -import { CoreSingleTimeEventObserver } from '@singletons/events'; +import { CoreCancellablePromise } from '@classes/cancellable-promise'; /** * Directive to format text rendered. It renders the HTML and treats all links and media, using CoreLinkDirective @@ -90,7 +90,7 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy { protected element: HTMLElement; protected emptyText = ''; protected contentSpan: HTMLElement; - protected domListener?: CoreSingleTimeEventObserver; + protected domPromise?: CoreCancellablePromise; constructor( element: ElementRef, @@ -133,7 +133,7 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy { * @inheritdoc */ ngOnDestroy(): void { - this.domListener?.off(); + this.domPromise?.cancel(); } /** @@ -556,8 +556,9 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy { * @return The width of the element in pixels. */ protected async getElementWidth(): Promise { - this.domListener = CoreDomUtils.waitToBeInDOM(this.element); - await this.domListener.promise; + this.domPromise = CoreDomUtils.waitToBeInDOM(this.element); + + await this.domPromise; let width = this.element.getBoundingClientRect().width; if (!width) { @@ -711,7 +712,7 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy { let width: string | number; let height: string | number; - await CoreDomUtils.waitToBeInDOM(iframe, 5000).promise; + await CoreDomUtils.waitToBeInDOM(iframe, 5000); if (iframe.width) { width = iframe.width; diff --git a/src/core/directives/on-appear.ts b/src/core/directives/on-appear.ts index 16519d58a..267fd3f9f 100644 --- a/src/core/directives/on-appear.ts +++ b/src/core/directives/on-appear.ts @@ -13,8 +13,8 @@ // limitations under the License. import { Directive, ElementRef, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; +import { CoreCancellablePromise } from '@classes/cancellable-promise'; import { CoreDomUtils } from '@services/utils/dom'; -import { CoreSingleTimeEventObserver } from '@singletons/events'; /** * Directive to listen when an element becomes visible. @@ -27,7 +27,7 @@ export class CoreOnAppearDirective implements OnInit, OnDestroy { @Output() onAppear = new EventEmitter(); private element: HTMLElement; - protected domListener?: CoreSingleTimeEventObserver; + protected domPromise?: CoreCancellablePromise; constructor(element: ElementRef) { this.element = element.nativeElement; @@ -37,8 +37,9 @@ export class CoreOnAppearDirective implements OnInit, OnDestroy { * @inheritdoc */ async ngOnInit(): Promise { - this.domListener = CoreDomUtils.waitToBeInDOM(this.element); - await this.domListener.promise; + this.domPromise = CoreDomUtils.waitToBeInDOM(this.element); + + await this.domPromise; this.onAppear.emit(); } @@ -47,7 +48,7 @@ export class CoreOnAppearDirective implements OnInit, OnDestroy { * @inheritdoc */ ngOnDestroy(): void { - this.domListener?.off(); + this.domPromise?.cancel(); } } diff --git a/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts b/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts index 66b451146..550682a86 100644 --- a/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts +++ b/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts @@ -36,11 +36,12 @@ import { CoreDomUtils } from '@services/utils/dom'; import { CoreUrlUtils } from '@services/utils/url'; import { CoreUtils } from '@services/utils/utils'; import { Platform, Translate } from '@singletons'; -import { CoreEventFormActionData, CoreEventObserver, CoreEvents, CoreSingleTimeEventObserver } from '@singletons/events'; +import { CoreEventFormActionData, CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreEditorOffline } from '../../services/editor-offline'; import { CoreComponentsRegistry } from '@singletons/components-registry'; import { CoreLoadingComponent } from '@components/loading/loading'; import { CoreScreen } from '@services/screen'; +import { CoreCancellablePromise } from '@classes/cancellable-promise'; /** * Component to display a rich text editor if enabled. @@ -105,7 +106,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn protected selectionChangeFunction?: () => void; protected languageChangedSubscription?: Subscription; protected resizeListener?: CoreEventObserver; - protected domListener?: CoreSingleTimeEventObserver; + protected domPromise?: CoreCancellablePromise; rteEnabled = false; isPhone = false; @@ -288,8 +289,9 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn * @return Promise resolved when loadings are done. */ protected async waitLoadingsDone(): Promise { - this.domListener = CoreDomUtils.waitToBeInDOM(this.element); - await this.domListener.promise; + this.domPromise = CoreDomUtils.waitToBeInDOM(this.element); + + await this.domPromise; const page = this.element.closest('.ion-page'); @@ -829,7 +831,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn const length = await this.toolbarSlides.length(); - await CoreDomUtils.waitToBeInDOM(this.toolbar.nativeElement, 5000).promise; + await CoreDomUtils.waitToBeInDOM(this.toolbar.nativeElement, 5000); const width = this.toolbar.nativeElement.getBoundingClientRect().width; @@ -1089,7 +1091,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn this.keyboardObserver?.off(); this.labelObserver?.disconnect(); this.resizeListener?.off(); - this.domListener?.off(); + this.domPromise?.cancel(); } } diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index 7b568abb8..380064f49 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -53,7 +53,8 @@ import { NavigationStart } from '@angular/router'; import { filter } from 'rxjs/operators'; import { Subscription } from 'rxjs'; import { CoreComponentsRegistry } from '@singletons/components-registry'; -import { CoreEventObserver, CoreSingleTimeEventObserver } from '@singletons/events'; +import { CoreEventObserver } from '@singletons/events'; +import { CoreCancellablePromise } from '@classes/cancellable-promise'; /* * "Utils" service with helper functions for UI, DOM elements and HTML code. @@ -97,51 +98,46 @@ export class CoreDomUtilsProvider { * * @param element Element to wait. * @param timeout If defined, timeout to wait before rejecting the promise. - * @return Promise CoreSingleTimeEventObserver with a promise. + * @return Cancellable promise. */ - waitToBeInDOM( - element: Element, - timeout?: number, - ): CoreSingleTimeEventObserver { - let root = element.getRootNode({ composed: true }); + waitToBeInDOM(element: Element, timeout?: number): CoreCancellablePromise { + const root = element.getRootNode({ composed: true }); if (root === document) { // Already in DOM. - return { - off: (): void => { - // Nothing to do here. - }, - promise: Promise.resolve(), - }; + return CoreCancellablePromise.resolve(); } - let observer: MutationObserver | undefined; + let observer: MutationObserver; let observerTimeout: number | undefined; - if (timeout) { - observerTimeout = window.setTimeout(() => { - observer?.disconnect(); - throw new Error('Waiting for DOM timeout reached'); - }, timeout); - } - return { - off: (): void => { - observer?.disconnect(); - clearTimeout(observerTimeout); - }, - promise: new Promise((resolve) => { + return new CoreCancellablePromise( + (resolve, reject) => { observer = new MutationObserver(() => { - root = element.getRootNode({ composed: true }); + const root = element.getRootNode({ composed: true }); + if (root === document) { observer?.disconnect(); - clearTimeout(observerTimeout); + observerTimeout && clearTimeout(observerTimeout); resolve(); } }); + if (timeout) { + observerTimeout = window.setTimeout(() => { + observer?.disconnect(); + + reject(new Error('Waiting for DOM timeout reached')); + }, timeout); + } + observer.observe(document.body, { subtree: true, childList: true }); - }), - }; + }, + () => { + observer?.disconnect(); + observerTimeout && clearTimeout(observerTimeout); + }, + ); } /** diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index 33042ddfc..d1d805e6c 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -31,21 +31,6 @@ export interface CoreEventObserver { off: () => void; } -/** - * Observer instance to stop listening to an observer. - */ -export interface CoreSingleTimeEventObserver { - /** - * Stop the observer. - */ - off: () => void; - - /** - * Promise Resolved when event is done (first time). - */ - promise: Promise; -} - /** * Event payloads. */