MV3: Can .addListener() be inside a function?

In MV3/nonpersistent background pages, we are told to avoid registering WE listeners asynchronously. Is it safe to register listeners inside a function/class, as long as it is called initially, not asynchronously?

Yes, it’s safe. No matter how nested the registration is, it’s totally fine as long as it’s executed in the first iteration of the JavaScript event loop.

Most probably even callbacks of “already resolved promises” would be good enough since these goes into microtasks which are executed before event loop starts to process a new task.
One could test this by registering handler inside a:
Promise.resolve(() => browser.runtime.onMessage.addListener(...))

However, I think writing a class handling an event will not work, as the class object would be discarded. Is this true?

We now have this code:


import browser from 'webextension-polyfill';
import { EventSink } from 'weeg-events';

export type StorageArea = 'local' | 'sync' | 'managed';

export class StorageItem<T> {
  public static readonly AREA_LOCAL = 'local';
  public static readonly AREA_SYNC = 'sync';
  public static readonly AREA_MANAGED = 'managed';

  private readonly area: browser.Storage.StorageArea | undefined;
  public readonly key: string;
  public readonly areaName: 'managed' | 'local' | 'sync';
  public readonly defaultValue: T;

  public readonly onChanged = new EventSink<T>();

  private readonly observers: Set<(newValue: T) => void> = new Set();

  public constructor(key: string, defaultValue: T, area: StorageArea) {
    this.key = key;
    this.defaultValue = defaultValue;
    this.areaName = area;
    if (!browser.storage) {
      console.warn('browser.storage is not available, storage will not work');
      return;
    }
    switch (area) {
      case StorageItem.AREA_LOCAL: {
        this.area = browser.storage.local;
        this.areaName = 'local';
        break;
      }

      case StorageItem.AREA_SYNC: {
        this.area = browser.storage.sync;
        this.areaName = 'sync';
        break;
      }

      case StorageItem.AREA_MANAGED: {
        this.area = browser.storage.managed;
        this.areaName = 'managed';
        break;
      }

      default: {
        throw new Error(`Unknown storage area: ${area}`);
      }
    }

    browser.storage.onChanged.addListener((changes, areaName) => {
      if (!(this.key in changes)) return;
      if (areaName != this.areaName) return;
      const change = changes[this.key];
      if (!change) return;
      const { newValue } = change;
      const value = undefined !== newValue ? newValue : this.defaultValue;
      this.onChanged.dispatch(value);
      try {
        this.observers.forEach((observer) => observer(value));
      } catch (e) {
        console.error(e);
      }
    });
  }

  public get storageAvailable(): boolean {
    return !!this.area;
  }

  private async getRawValue(): Promise<T | undefined> {
    try {
      if (!this.area) return undefined;
      const data = await this.area.get(this.key);
      const value = data[this.key];
      return value as T;
    } catch (_e) {
      // throws in case of uninitialized managed storage
      // we want to return undefined in that case
      return undefined;
    }
  }

  public async hasValue(): Promise<boolean> {
    const value = await this.getRawValue();
    return value !== undefined;
  }

  public async getValue(): Promise<T> {
    const value = await this.getRawValue();
    if (value === undefined) {
      return this.defaultValue;
    }
    return value;
  }

  public async clearValue(): Promise<void> {
    if (this.areaName == 'managed') {
      throw new Error('Cannot clear managed storage');
    }
    if (!this.area) return;
    await this.area.remove(this.key);
  }

  public async setValue(value: T): Promise<void> {
    if (this.areaName == 'managed') {
      throw new Error('Cannot set managed storage');
    }
    if (!this.area) return;
    await this.area.set({ [this.key]: value });
  }

  public observe(callback: (newValue: T) => void, reportCurrentValue = true): void {
    if (reportCurrentValue) {
      this.getValue().then(callback);
    }
    this.observers.add(callback);
  }

  public unobserve(callback: (newValue: T) => void): void {
    this.observers.delete(callback);
  }
}

const storage = new StorageItem<boolean>("testBooleanValue", false, StorageItem.AREA_LOCAL);
storage.observe((value) => console.log('value changed:', value));

Or in this example, if observe() callback is registered in initial execution, it is restored on resume?

That depends on the “target” value in your “tsconfig.json” file. You see, JavaScript supports classes for many years now, so if you set your target to something like “es2021”, it will keep them there (unless you have some other post-processing that would again “downgrade” your javascript).

But even if you would target some very old ES5, the code would be still there, just wrapped in some polyfill, but it wouldn’t be suddenly asynchronous. So I don’t see why it wouldn’t work.

But if you want to be sure, you can create simple “assert” function to verify that behavior. I’ve build this when I started to write migration to MV3 and I wasn’t sure if my main handlers are run in the first iteration:

// file "assert.top.ts"

// top level starts as TRUE and is set to FALSE the moment current loop finishes.
let isTopLevel = true;
Promise.resolve().then(() => isTopLevel = false);

// throws if executed later on - this helps with Manifest V3 migration!
export function assertTopLevel() {
  if (!isTopLevel) throw Error('WARNING: this is not a top level event loop');
}

Then you simply call the “assertTopLevel()” in the place where you register the event handler.

2 Likes