From 9f910d338fd9f6826dcd550a778c96e41061b192 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 31 Jul 2024 09:56:42 +0200 Subject: [PATCH 1/2] MOBILE-4612 compile: Expose model and untracked to plugins --- src/core/features/compile/services/compile.ts | 19 ++--- src/core/utils/signals.ts | 73 +++++++++++++++++++ 2 files changed, 79 insertions(+), 13 deletions(-) create mode 100644 src/core/utils/signals.ts diff --git a/src/core/features/compile/services/compile.ts b/src/core/features/compile/services/compile.ts index 476c60c0d..b22e45494 100644 --- a/src/core/features/compile/services/compile.ts +++ b/src/core/features/compile/services/compile.ts @@ -23,10 +23,7 @@ import { ViewContainerRef, signal, computed, - effect, - EffectCleanupRegisterFn, - CreateEffectOptions, - EffectRef, + untracked, } from '@angular/core'; import { ActionSheetController, @@ -41,6 +38,7 @@ import { TranslateService } from '@ngx-translate/core'; import { CoreLogger } from '@singletons/logger'; import { CoreEvents } from '@singletons/events'; import { makeSingleton } from '@singletons'; +import { effectWithInjectionContext, modelWithInjectionContext } from '@/core/utils/signals'; // Import core services. import { getCoreServices } from '@/core/core.module'; @@ -306,15 +304,10 @@ export class CoreCompileProvider { instance['Md5'] = Md5; instance['signal'] = signal; instance['computed'] = computed; - // Create a wrapper to call effect with the proper injection context. - instance['effect'] = ( - effectFn: (onCleanup: EffectCleanupRegisterFn) => void, - options?: Omit, - ): EffectRef => - effect(effectFn, { - ...options, - injector, - }); + instance['untracked'] = untracked; + instance['effect'] = effectWithInjectionContext(injector); + instance['model'] = modelWithInjectionContext(injector); + /** * @deprecated since 4.1, plugins should use CoreNetwork instead. * Keeping this a bit more to avoid plugins breaking. diff --git a/src/core/utils/signals.ts b/src/core/utils/signals.ts new file mode 100644 index 000000000..259e25879 --- /dev/null +++ b/src/core/utils/signals.ts @@ -0,0 +1,73 @@ +// (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 { + CreateEffectOptions, + effect, + EffectCleanupRegisterFn, + EffectRef, + Injector, + model, + ModelOptions, + ModelSignal, + runInInjectionContext, +} from '@angular/core'; + +/** + * Return an effect wrapper that can be used to create an effect with a certain injection context. + * Example: + * + * ``` + * const effectWrapper = effectWithInjectionContext(injector); + * + * effectWrapper(() => { + * // Your effect code here. + * }); + * ``` + * + * @param injector Injector to use for the effect. + * @returns Function to create the effect. + */ +export function effectWithInjectionContext(injector: Injector): typeof effect { + return ( + effectFn: (onCleanup: EffectCleanupRegisterFn) => void, + options?: Omit, + ): EffectRef => + effect(effectFn, { + ...options, + injector, + }); +} + +/** + * Return a model wrapper that can be used to create a model with a certain injection context. + * Example: + * + * ``` + * const modelWrapper = modelWithInjectionContext(injector); + * + * const myModel = modelWrapper(''); + * ``` + * + * @param injector Injector to use for the model. + * @returns Function to create the model. + */ +export function modelWithInjectionContext(injector: Injector): typeof model { + const modelFunction = (initialValue: T, opts?: ModelOptions): ModelSignal => + runInInjectionContext(injector, () => model(initialValue, opts)); + + modelFunction.required = (opts?: ModelOptions): ModelSignal => runInInjectionContext(injector, () => model.required(opts)); + + return modelFunction as typeof model; +} From e8610272c782384621fe024fccb7485cd7b97723 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 31 Jul 2024 10:00:08 +0200 Subject: [PATCH 2/2] MOBILE-4612 compile: Improve destroying effects --- .../components/compile-html/compile-html.ts | 31 ++++++++++++++++--- src/core/features/compile/services/compile.ts | 22 +++++++++---- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/core/features/compile/components/compile-html/compile-html.ts b/src/core/features/compile/components/compile-html/compile-html.ts index dd6b6edbc..2cceb8f0f 100644 --- a/src/core/features/compile/components/compile-html/compile-html.ts +++ b/src/core/features/compile/components/compile-html/compile-html.ts @@ -13,6 +13,7 @@ // limitations under the License. import { toBoolean } from '@/core/transforms/boolean'; +import { effectWithInjectionContext } from '@/core/utils/signals'; import { Component, Input, @@ -34,6 +35,9 @@ import { Type, KeyValueDiffer, Injector, + EffectRef, + EffectCleanupRegisterFn, + CreateEffectOptions, } from '@angular/core'; import { CorePromisedValue } from '@classes/promised-value'; @@ -212,6 +216,7 @@ export class CoreCompileHtmlComponent implements OnChanges, OnDestroy, DoCheck { return class CoreCompileHtmlFakeComponent implements OnInit, AfterContentInit, AfterViewInit, OnDestroy { private ongoingLifecycleHooks: Set = new Set(); + protected effectRefs: EffectRef[] = []; constructor() { // Store this instance so it can be accessed by the outer component. @@ -221,12 +226,25 @@ export class CoreCompileHtmlComponent implements OnChanges, OnDestroy, DoCheck { this['dataObject'] = {}; this['dataArray'] = []; + const effectWithContext = effectWithInjectionContext(compileInstance.injector); + // Inject the libraries. - CoreCompile.injectLibraries( - this, - compileInstance.extraProviders, - compileInstance.injector, - ); + CoreCompile.injectLibraries(this, { + extraLibraries: compileInstance.extraProviders, + injector: compileInstance.injector, + // Capture calls to effect to retrieve the effectRefs and destroy them when this component is destroyed. + // Otherwise effects are only destroyed when the parent component is destroyed. + effectWrapper: ( + effectFn: (onCleanup: EffectCleanupRegisterFn) => void, + options?: Omit, + ): EffectRef => { + const effectRef = effectWithContext(effectFn, options); + + this.effectRefs.push(effectRef); + + return effectRef; + }, + }); // Always add these elements, they could be needed on component init (componentObservable). this['ChangeDetectorRef'] = compileInstance.changeDetector; @@ -280,6 +298,9 @@ export class CoreCompileHtmlComponent implements OnChanges, OnDestroy, DoCheck { * @inheritdoc */ ngOnDestroy(): void { + this.effectRefs.forEach(effectRef => effectRef.destroy()); + this.effectRefs = []; + this.callLifecycleHookOverride('ngOnDestroy'); } diff --git a/src/core/features/compile/services/compile.ts b/src/core/features/compile/services/compile.ts index b22e45494..d142682cf 100644 --- a/src/core/features/compile/services/compile.ts +++ b/src/core/features/compile/services/compile.ts @@ -23,6 +23,7 @@ import { ViewContainerRef, signal, computed, + effect, untracked, } from '@angular/core'; import { @@ -261,20 +262,19 @@ export class CoreCompileProvider { * Inject all the core libraries in a certain object. * * @param instance The instance where to inject the libraries. - * @param extraLibraries Extra imported providers if needed and not imported by this class. - * @param injector Injector of the injection context. E.g. for a component, use the component's injector. + * @param options Options. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - injectLibraries(instance: any, extraLibraries: Type[] = [], injector?: Injector): void { + injectLibraries(instance: any, options: InjectLibrariesOptions = {}): void { if (!this.libraries || !this.exportedObjects) { throw new CoreError('Libraries not loaded. You need to call loadLibraries before calling injectLibraries.'); } const libraries = [ ...this.libraries, - ...extraLibraries, + ...options.extraLibraries ?? [], ]; - injector = injector ?? this.injector; + const injector = options.injector ?? this.injector; // We cannot inject anything to this constructor. Use the Injector to inject all the providers into the instance. for (const i in libraries) { @@ -305,7 +305,7 @@ export class CoreCompileProvider { instance['signal'] = signal; instance['computed'] = computed; instance['untracked'] = untracked; - instance['effect'] = effectWithInjectionContext(injector); + instance['effect'] = options.effectWrapper ?? effectWithInjectionContext(injector); instance['model'] = modelWithInjectionContext(injector); /** @@ -430,3 +430,13 @@ export class CoreCompileProvider { } export const CoreCompile = makeSingleton(CoreCompileProvider); + +/** + * Options for injectLibraries. + */ +type InjectLibrariesOptions = { + extraLibraries?: Type[]; // Extra imported providers if needed and not imported by this class. + injector?: Injector; // Injector of the injection context. E.g. for a component, use the component's injector. + effectWrapper?: typeof effect; // Wrapper function to create an effect. If not provided, a wrapper will be created using the + // injector. Use this wrapper if you want to capture the created EffectRefs. +};