Feature: context consumer auto destroys if no callback + getContext options (#18835)

* consumer destroys when no callback

* getContext can be simplification

* JSDocs

* implement options to skipHost and pass context alias matches
This commit is contained in:
Niels Lyngsø
2025-03-31 10:32:36 +02:00
committed by GitHub
parent 1e5d29e667
commit 3446b77d7d
5 changed files with 95 additions and 61 deletions

View File

@@ -9,6 +9,11 @@ import type { UmbControllerAlias, UmbControllerHost } from '@umbraco-cms/backoff
import type { ObserverCallback, UmbObserverController } from '@umbraco-cms/backoffice/observable-api';
import type { Observable } from '@umbraco-cms/backoffice/external/rxjs';
export interface UmbClassGetContextOptions extends UmbContextConsumerAsPromiseOptionsType {
skipHost?: boolean;
passContextAliasMatches?: boolean;
}
export interface UmbClassInterface extends UmbControllerHost {
/**
* @description Observe an Observable. An Observable is a declared source of data that can be observed. An observables is declared from a UmbState.
@@ -60,11 +65,11 @@ export interface UmbClassInterface extends UmbControllerHost {
/**
* @description Retrieve a context. Notice this is a one time retrieving of a context, meaning if you expect this to be up to date with reality you should instead use the consumeContext method.
* @param {string} alias
* @returns {Promise<ContextType>} A Promise with the reference to the Context Api Instance
* @returns {Promise<unknown>} A Promise with the reference to the Context Api Instance
* @memberof UmbClassInterface
*/
getContext<BaseType = unknown, ResultType extends BaseType = BaseType>(
alias: string | UmbContextToken<BaseType, ResultType>,
options?: UmbContextConsumerAsPromiseOptionsType,
options?: UmbClassGetContextOptions,
): Promise<ResultType | undefined>;
}

View File

@@ -1,4 +1,5 @@
import type { UmbClassMixinInterface } from './class-mixin.interface.js';
import type { UmbClassGetContextOptions } from './class.interface.js';
import type { Observable } from '@umbraco-cms/backoffice/external/rxjs';
import type { ClassConstructor } from '@umbraco-cms/backoffice/extension-api';
import {
@@ -11,7 +12,6 @@ import {
type UmbContextCallback,
UmbContextConsumerController,
UmbContextProviderController,
type UmbContextConsumerAsPromiseOptionsType,
} from '@umbraco-cms/backoffice/context-api';
import { type ObserverCallback, UmbObserverController, simpleHashCode } from '@umbraco-cms/backoffice/observable-api';
@@ -99,20 +99,18 @@ export const UmbClassMixin = <T extends ClassConstructor<EventTarget>>(superClas
async getContext<BaseType = unknown, ResultType extends BaseType = BaseType>(
contextAlias: string | UmbContextToken<BaseType, ResultType>,
options?: UmbContextConsumerAsPromiseOptionsType,
options?: UmbClassGetContextOptions,
): Promise<ResultType | undefined> {
const controller = new UmbContextConsumerController(this, contextAlias);
const promise = controller
.asPromise(options)
.then((result) => {
controller.destroy();
return result;
})
.catch(() => {
controller.destroy();
return undefined;
});
return promise;
if (options) {
if (options.passContextAliasMatches) {
controller.passContextAliasMatches();
}
if (options.skipHost) {
controller.skipHost();
}
}
return controller.asPromise(options);
}
public override destroy(): void {

View File

@@ -100,6 +100,23 @@ describe('UmbContextConsumer', () => {
provider.hostConnected();
});
it('auto destroys when no callback provided', async () => {
const provider = new UmbContextProvider(document.body, testContextAlias, new UmbTestContextConsumerClass());
const localConsumer = new UmbContextConsumer<UmbTestContextConsumerClass>(element, testContextAlias);
expect((localConsumer as any)._retrieveHost).to.not.be.undefined;
localConsumer.hostConnected();
provider.hostConnected();
const instance = await localConsumer.asPromise().catch(() => {
expect.fail('Promise should not reject');
});
expect(instance?.prop).to.eq('value from provider');
provider.hostDisconnected();
await Promise.resolve();
expect((localConsumer as any)._retrieveHost).to.be.undefined;
});
it('gets rejected when using asPromise that does not resolve', (done) => {
const localConsumer = new UmbContextConsumer<UmbTestContextConsumerClass>(element, testContextAlias);
@@ -116,29 +133,31 @@ describe('UmbContextConsumer', () => {
localConsumer.hostConnected();
});
it('never gets rejected when using asPromise that is set not to timeout and never will resolve', (done) => {
it('never gets rejected when using asPromise that is set to prevent timeout and never will resolve', (done) => {
const localConsumer = new UmbContextConsumer<UmbTestContextConsumerClass>(element, testContextAlias);
localConsumer.hostConnected();
const timeout = setTimeout(() => {
localConsumer.hostDisconnected();
done();
}, 200);
let acceptedRejection = false;
try {
localConsumer
.asPromise({ preventTimeout: true })
.then((instance) => {
clearTimeout(timeout);
expect.fail('Promise should not resolve');
})
.catch(() => {
clearTimeout(timeout);
const timeout = setTimeout(() => {
acceptedRejection = true;
localConsumer.hostDisconnected();
}, 100);
localConsumer
.asPromise({ preventTimeout: true })
.then((instance) => {
clearTimeout(timeout);
expect.fail('Promise should not resolve');
})
.catch((e) => {
clearTimeout(timeout);
if (acceptedRejection === true) {
done();
} else {
expect.fail('Promise should not reject');
});
} catch (e) {
console.log('e', e);
}
}
});
});
it('works with host as a method', (done) => {
@@ -375,7 +394,7 @@ describe('UmbContextConsumer', () => {
});
});
it('context api of same context alias will NOT prevent request from propagating when set to exactMatch', (done) => {
it('context api of same context alias will NOT prevent request from propagating when set to passContextAliasMatches', (done) => {
const provider = new UmbContextProvider(document.body, testContextAlias, new UmbTestContextConsumerClass());
provider.hostConnected();

View File

@@ -23,6 +23,7 @@ export class UmbContextConsumer<BaseType = unknown, ResultType extends BaseType
#stopAtContextMatch = true;
#callback?: UmbContextCallback<ResultType>;
#promise?: Promise<ResultType | undefined>;
#promiseOptions?: UmbContextConsumerAsPromiseOptionsType;
#promiseResolver?: (instance: ResultType) => void;
#promiseRejecter?: (reason: string) => void;
@@ -115,9 +116,13 @@ export class UmbContextConsumer<BaseType = unknown, ResultType extends BaseType
if (this.#promiseResolver && this.#instance !== undefined) {
this.#promiseResolver(this.#instance);
this.#promise = undefined;
this.#promiseOptions = undefined;
this.#promiseResolver = undefined;
this.#promiseRejecter = undefined;
}
if (!this.#callback) {
this.destroy();
}
}
#rejectPromise() {
@@ -128,9 +133,13 @@ export class UmbContextConsumer<BaseType = unknown, ResultType extends BaseType
`Context could not be found. (Context Alias: ${this.#contextAlias} with API Alias: ${this.#apiAlias}). Controller is hosted on ${hostElement?.parentNode?.nodeName ?? 'Not attached node'} > ${hostElement?.nodeName}`,
);
this.#promise = undefined;
this.#promiseOptions = undefined;
this.#promiseResolver = undefined;
this.#promiseRejecter = undefined;
}
if (!this.#callback) {
this.destroy();
}
}
/**
@@ -145,12 +154,14 @@ export class UmbContextConsumer<BaseType = unknown, ResultType extends BaseType
this.#promise ??
(this.#promise = new Promise<ResultType | undefined>((resolve, reject) => {
if (this.#instance) {
this.#promiseOptions = undefined;
this.#promiseResolver = undefined;
this.#promiseRejecter = undefined;
resolve(this.#instance);
} else {
this.#promiseOptions = options;
this.#promiseResolver = resolve;
this.#promiseRejecter = options?.preventTimeout ? undefined : reject;
this.#promiseRejecter = reject;
}
}))
);
@@ -181,11 +192,13 @@ export class UmbContextConsumer<BaseType = unknown, ResultType extends BaseType
}
*/
this.#raf = requestAnimationFrame(() => {
// For unproviding, then setInstance to undefined here. [NL]
this.#rejectPromise();
this.#raf = undefined;
});
if (this.#promiseResolver && this.#promiseOptions?.preventTimeout !== true) {
this.#raf = requestAnimationFrame(() => {
// For unproviding, then setInstance to undefined here. [NL]
this.#rejectPromise();
this.#raf = undefined;
});
}
}
public hostConnected(): void {
@@ -199,11 +212,14 @@ export class UmbContextConsumer<BaseType = unknown, ResultType extends BaseType
if (this.#raf !== undefined) {
cancelAnimationFrame(this.#raf);
this.#raf = undefined;
this.#promiseRejecter?.('Context request was cancelled, host was disconnected.');
this.#promiseResolver = undefined;
this.#promiseRejecter = undefined;
this.#promise = undefined;
}
this.#promiseRejecter?.('Context request was cancelled, host was disconnected.');
this.#promise = undefined;
this.#promiseOptions = undefined;
this.#promiseResolver = undefined;
this.#promiseRejecter = undefined;
// TODO: We need to use closets application element. We need this in order to have separate Backoffice running within or next to each other.
window.removeEventListener(UMB_CONTEXT_PROVIDE_EVENT_TYPE, this.#handleNewProvider);
//window.removeEventListener(umbContextUnprovidedEventType, this.#handleRemovedProvider);
@@ -242,6 +258,7 @@ export class UmbContextConsumer<BaseType = unknown, ResultType extends BaseType
this._retrieveHost = undefined as any;
this.#callback = undefined;
this.#promise = undefined;
this.#promiseOptions = undefined;
this.#promiseResolver = undefined;
this.#promiseRejecter = undefined;
this.#instance = undefined;

View File

@@ -3,14 +3,11 @@ import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-
import type { Observable } from '@umbraco-cms/backoffice/external/rxjs';
import type { HTMLElementConstructor } from '@umbraco-cms/backoffice/extension-api';
import { type UmbControllerAlias, UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api';
import type {
UmbContextToken,
UmbContextCallback,
UmbContextConsumerAsPromiseOptionsType,
} from '@umbraco-cms/backoffice/context-api';
import type { UmbContextToken, UmbContextCallback } from '@umbraco-cms/backoffice/context-api';
import { UmbContextConsumerController, UmbContextProviderController } from '@umbraco-cms/backoffice/context-api';
import type { ObserverCallback } from '@umbraco-cms/backoffice/observable-api';
import { UmbObserverController, simpleHashCode } from '@umbraco-cms/backoffice/observable-api';
import type { UmbClassGetContextOptions } from '@umbraco-cms/backoffice/class-api';
export const UmbElementMixin = <T extends HTMLElementConstructor>(superClass: T) => {
class UmbElementMixinClass extends UmbControllerHostElementMixin(superClass) implements UmbElement {
@@ -78,20 +75,18 @@ export const UmbElementMixin = <T extends HTMLElementConstructor>(superClass: T)
async getContext<BaseType = unknown, ResultType extends BaseType = BaseType>(
contextAlias: string | UmbContextToken<BaseType, ResultType>,
options?: UmbContextConsumerAsPromiseOptionsType,
options?: UmbClassGetContextOptions,
): Promise<ResultType | undefined> {
const controller = new UmbContextConsumerController(this, contextAlias);
const promise = controller
.asPromise(options)
.then((result) => {
controller.destroy();
return result;
})
.catch(() => {
controller.destroy();
return undefined;
});
return promise;
if (options) {
if (options.passContextAliasMatches) {
controller.passContextAliasMatches();
}
if (options.skipHost) {
controller.skipHost();
}
}
return controller.asPromise(options);
}
}