Custom ContextBase doesn't automatically remove listeners?

I encountered this strange issue while working on SeoToolkit. I have a context that is sometimes enabled and sometimes isn’t based on a condition. In that context, I do some subscribing to various elements (whole code is below). However, for some reason those subscriptions aren’t removed whenever the context is destroyed and I am not sure why….

By adding some console.logs, I keep seeing the amount of logs increase as I navigate through my application. I think I could unsubscribe to them when the context is being destroyed, but I also don’t see that happening in the Umbraco source code, so I am a bit confused about that.

import { UmbContextBase } from "@umbraco-cms/backoffice/class-api";
import {
  MetaFieldsSettingsViewModel,
} from "../api";
import { UmbWorkspaceContext } from "@umbraco-cms/backoffice/workspace";
import { UmbContextToken } from "@umbraco-cms/backoffice/context-api";
import { MetaFieldsContentRepository } from "../dataAccess/MetaFieldsContentRepository";
import { UmbControllerHost } from "@umbraco-cms/backoffice/controller-api";
import { UMB_DOCUMENT_WORKSPACE_CONTEXT } from "@umbraco-cms/backoffice/document";
import { UmbObjectState } from "@umbraco-cms/backoffice/observable-api";

interface MetaFieldsSettingsVariant {
  variant: string;
  model: UmbObjectState<MetaFieldsSettingsViewModel>;
  lastUpdated?: string | null;
}

export default class MetaFieldsContentContext
  extends UmbContextBase
  implements UmbWorkspaceContext
{
  workspaceAlias: string = "Umb.Workspace.Document";

  #repository: MetaFieldsContentRepository;

  #nodeId?: string;
  #cultures: string[] = [];

  #variants: { [key: string]: MetaFieldsSettingsVariant } =
    {};

  constructor(host: UmbControllerHost) {
    super(host, ST_METAFIELDS_CONTENT_TOKEN_CONTEXT.toString());

    this.#repository = new MetaFieldsContentRepository(host);

    this.consumeContext(UMB_DOCUMENT_WORKSPACE_CONTEXT, (instance) => {
      this.#nodeId = instance?.getUnique()?.toString();

      instance?.splitView.activeVariantsInfo.subscribe((variants) => {
        variants.forEach((variant) => {
          const culture = variant.culture ?? "invariant";
          if (!this.#cultures.includes(culture)) {
            this.#cultures.push(culture);
          }
        });
        this.#loadDataFromRepository(this.#nodeId);
      });
      instance?.unique.subscribe((unique) => {
        console.log(unique);
        this.#cultures.forEach((culture) => {
          delete this.#variants[culture];
        })
        this.#loadDataFromRepository(unique?.toString());
      });
      instance?.data.subscribe((item) => {
        item?.variants.forEach((variant) => {
          const culture = variant.culture ?? 'invariant';
          const currentDate = this.#getVariant(culture).lastUpdated;
          if (currentDate && currentDate !== variant.updateDate) {
            this.save(culture);
          }

          this.#getVariant(culture).lastUpdated = variant.updateDate;
        })
      });
    });
  }

  destroy(): void {
      console.log('destroy context');
      super.destroy();
  }

  #loadDataFromRepository(node: string | undefined) {
    this.#nodeId = node;

    this.#cultures.forEach((variant) => {
      if (
        this.#variants[variant] &&
        this.#variants[variant].model.getValue().fields
      ) {
        return;
      }

      this.#repository.get(node!, variant).then((resp) => {
        this.#getVariant(variant).model.update(resp.data);
      });
    });
  }

  #getVariant(variant: string) {
    if (this.#variants[variant]) {
      return this.#variants[variant];
    }
    this.#variants[variant] = {
      variant,
      model: new UmbObjectState<MetaFieldsSettingsViewModel>({})
    }
    return this.#variants[variant];
  }

  getModel(variant: string) {
    return this.#getVariant(variant).model.asObservable();
  }

  save(culture: string) {
    const model = this.#variants[culture]!.model.getValue();
    const userValues: { [key: string]: unknown } = {};
    model.fields?.forEach((field) => {
      if (field.userValue) {
        userValues[field.alias!] = field.userValue;
      }
    });

    this.#repository.save({
      nodeId: this.#nodeId!,
      culture: culture,
      userValues: userValues,
    });
  }

  updateField(variant: string, alias: string, userValue: any) {
    const model = this.#variants[variant].model;
    const fields = [...model.getValue().fields!];

    const foundField = fields.find((item) => item.alias === alias);
    if (!foundField) {
      return;
    }
    fields[fields.indexOf(foundField)] = {
      ...foundField,
      userValue: userValue,
    };
    model.update({
      fields: fields,
    });
  }

  getEntityType(): string {
    return "st-metafield";
  }
}

export const ST_METAFIELDS_CONTENT_TOKEN_CONTEXT =
  new UmbContextToken<MetaFieldsContentContext>("ST-MetaFieldsContent-Context");

Hi @patrickdemooij9

Good topic, I hope we can figure out what is going on, and eventually you may help us ensure that Documentation coveres this, so the next person wont end in the same situation. :slight_smile:

This line, and the others doing the same, caught my attention imidiatly.
We provide an observation controller for RXJS subscription. One of the main reasons to use ours is to ensure the unsubscription happens when your controller is destroyed(taken out of action).

Instead, you should do this:

this.observe(instance?.splitView.activeVariantsInfo, (variants) => {
        variants.forEach((variant) => {
          const culture = variant.culture ?? "invariant";
          if (!this.#cultures.includes(culture)) {
            this.#cultures.push(culture);
          }
        });
        this.#loadDataFromRepository(this.#nodeId);
      }
);

Not relevant for your case, just extra information now that you got my attention: You can also declare a controller-alias as the third argument to this.observe(source, callback, ctrlAlias); this can be used to end the observation when you like, by a line like this this.removeUmbControllerByAlias("The-alias-you-gave-it")

You can read more about observations here: States | Umbraco CMS

And if you like more information on Controllers in general (Context consumption and State Observations and others), then you can read about it here: Umbraco Controller | Umbraco CMS

I hope that will work for you, and that you will let me know why you ended using the .subscription() that may lead us to how we can avoid others ending there.

Thanks in advance

Hi @nielslyngsoe

Thanks for getting back so quickly! I’ll give that a try later today and see if it works as expected.

As for why I chose the .subscribe(). I am not quite sure. I probably started using it when I moved my package to the new backoffice and then continued using it throughout my entire project as I didn’t know there was a better way of doing it. Maybe it was in one of the first examples? Not quite sure :sweat_smile:

That indeed seems to have done the job. Thank you!

1 Like

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.