MOBILE-3833 core: Implement cancellable promise

main
Noel De Martin 2022-03-15 17:33:20 +01:00
parent 1b6e578c41
commit 894e4a7b62
10 changed files with 208 additions and 127 deletions

View File

@ -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<T = unknown> extends CorePromise<T> {
/**
* Create a new resolved promise.
*
* @returns Resolved promise.
*/
static resolve(): CoreCancellablePromise<void>;
static resolve<T>(result: T): CoreCancellablePromise<T>;
static resolve<T>(result?: T): CoreCancellablePromise<T> {
return new this(resolve => result ? resolve(result) : (resolve as () => void)(), () => {
// Nothing to do here.
});
}
protected cancelPromise: () => void;
constructor(
executor: (resolve: (value: T | PromiseLike<T>) => 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();
}
}

View File

@ -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<T = unknown> implements Promise<T> {
protected nativePromise: Promise<T>;
constructor(nativePromise: Promise<T>) {
this.nativePromise = nativePromise;
}
[Symbol.toStringTag]: string;
/**
* @inheritdoc
*/
then<TResult1 = T, TResult2 = never>(
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null,
onRejected?: ((reason: Error) => TResult2 | PromiseLike<TResult2>) | undefined | null,
): Promise<TResult1 | TResult2> {
return this.nativePromise.then(onFulfilled, onRejected);
}
/**
* @inheritdoc
*/
catch<TResult = never>(
onRejected?: ((reason: Error) => TResult | PromiseLike<TResult>) | undefined | null,
): Promise<T | TResult> {
return this.nativePromise.catch(onRejected);
}
/**
* @inheritdoc
*/
finally(onFinally?: (() => void) | null): Promise<T> {
return this.nativePromise.finally(onFinally);
}
}

View File

@ -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<T = unknown> implements Promise<T> {
export class CorePromisedValue<T = unknown> extends CorePromise<T> {
/**
* Wrap an existing promise.
@ -33,20 +35,28 @@ export class CorePromisedValue<T = unknown> implements Promise<T> {
return promisedValue;
}
private _resolvedValue?: T;
private _rejectedReason?: Error;
declare private promise: Promise<T>;
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<T>((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<T = unknown> implements Promise<T> {
* @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<T = unknown> implements Promise<T> {
* @return Whether the promise was rejected.
*/
isRejected(): boolean {
return '_rejectedReason' in this;
return 'rejectedReason' in this;
}
/**
@ -76,32 +86,6 @@ export class CorePromisedValue<T = unknown> implements Promise<T> {
return this.isResolved() || this.isRejected();
}
/**
* @inheritdoc
*/
then<TResult1 = T, TResult2 = never>(
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null,
onRejected?: ((reason: Error) => TResult2 | PromiseLike<TResult2>) | undefined | null,
): Promise<TResult1 | TResult2> {
return this.promise.then(onFulfilled, onRejected);
}
/**
* @inheritdoc
*/
catch<TResult = never>(
onRejected?: ((reason: Error) => TResult | PromiseLike<TResult>) | undefined | null,
): Promise<T | TResult> {
return this.promise.catch(onRejected);
}
/**
* @inheritdoc
*/
finally(onFinally?: (() => void) | null): Promise<T> {
return this.promise.finally(onFinally);
}
/**
* Resolve the promise.
*
@ -109,13 +93,13 @@ export class CorePromisedValue<T = unknown> implements Promise<T> {
*/
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<T = unknown> implements Promise<T> {
*/
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;
});
}

View File

@ -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<void>;
constructor(el: ElementRef, protected ionContent: IonContent) {
this.element = el.nativeElement;
@ -61,10 +62,9 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy {
async ngOnInit(): Promise<void> {
// 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();
}
}

View File

@ -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<void>;
constructor(el: ElementRef<HTMLElement>) {
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<void> {
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();
}
}

View File

@ -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<void>;
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<number> {
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;

View File

@ -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<void>;
constructor(element: ElementRef) {
this.element = element.nativeElement;
@ -37,8 +37,9 @@ export class CoreOnAppearDirective implements OnInit, OnDestroy {
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
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();
}
}

View File

@ -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<void>;
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<void> {
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();
}
}

View File

@ -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<void> {
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<void>(
(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);
},
);
}
/**

View File

@ -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<void>;
}
/**
* Event payloads.
*/