Repository: dermotduffy/frigate-hass-card
Pull request #1545 (2024-09-19 04:28:38)

fix: Fix initialization race condition that occurs in certain circumstances
* Potential fix for #1533 


Patch:
diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts
index 93dd9a3b..8a6af9a3 100644
--- a/src/camera-manager/manager.ts
+++ b/src/camera-manager/manager.ts
@@ -111,7 +111,6 @@ export class CameraManager {
   protected _api: CardCameraAPI;
   protected _engineFactory: CameraManagerEngineFactory;
   protected _store: CameraManagerStore;
-  protected _initializationLimit = new PQueue({ concurrency: 1 });
   protected _requestLimit = new PQueue();
 
   constructor(
@@ -147,16 +146,8 @@ export class CameraManager {
       recursivelyMergeObjectsNotArrays({}, cloneDeep(config?.cameras_global), camera),
     );
 
-    const resetAndInitialize = async () => {
-      await this._reset();
-      await this._initializeCameras(cameras);
-    };
-
     try {
-      // This concurrency limit prevents multiple rapidly arriving configs from
-      // generating reset-n-initialize race conditions (e.g. changing values
-      // rapidly in the config editor).
-      await this._initializationLimit.add(resetAndInitialize);
+      await this._initializeCameras(cameras);
     } catch (e: unknown) {
       this._api
         .getMessageManager()
@@ -166,7 +157,7 @@ export class CameraManager {
     return true;
   }
 
-  protected async _reset(): Promise<void> {
+  public async reset(): Promise<void> {
     await this._store.reset();
   }
 
@@ -246,6 +237,8 @@ export class CameraManager {
       async ([cameraConfig, engine]) => await engine.createCamera(hass, cameraConfig),
     );
 
+    const cameraIDs: Set<string> = new Set();
+
     // Do the additions based off the result-order, to ensure the map order is
     // preserved.
     cameras.forEach((camera) => {
@@ -258,7 +251,7 @@ export class CameraManager {
         );
       }
 
-      if (this._store.hasCameraID(cameraID)) {
+      if (cameraIDs.has(cameraID)) {
         throw new CameraInitializationError(
           localize('error.duplicate_camera_id'),
           camera.getConfig(),
@@ -267,9 +260,11 @@ export class CameraManager {
 
       // Always ensure the actual ID used in the card is in the configuration itself.
       camera.setID(cameraID);
-      this._store.addCamera(camera);
+      cameraIDs.add(cameraID);
     });
 
+    await this._store.setCameras(cameras);
+
     log(
       this._api.getConfigManager().getCardWideConfig(),
       'Frigate Card CameraManager initialized (Cameras: ',
diff --git a/src/camera-manager/store.ts b/src/camera-manager/store.ts
index 1cc58742..58ef7906 100644
--- a/src/camera-manager/store.ts
+++ b/src/camera-manager/store.ts
@@ -44,6 +44,30 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
     this._enginesByType.set(camera.getEngine().getEngineType(), camera.getEngine());
   }
 
+  public async setCameras(cameras: Camera[]): Promise<void> {
+    // In setting the store cameras, take great care to replace/add first before
+    // remove. Otherwise, there may be race conditions where the card attempts
+    // to render a view with (momentarily) no camera.
+    // See: https://github.com/dermotduffy/frigate-hass-card/issues/1533
+
+    // Replace/Add the new cameras.
+    for (const camera of cameras) {
+      const oldCamera = this._cameras.get(camera.getID());
+      if (oldCamera !== camera) {
+        this.addCamera(camera);
+        await oldCamera?.destroy();
+      }
+    }
+
+    // Remove the old cameras.
+    for (const camera of this._cameras.values()) {
+      if (!cameras.includes(camera)) {
+        await camera.destroy();
+        this._cameras.delete(camera.getID());
+      }
+    }
+  }
+
   public async reset(): Promise<void> {
     await allPromises(this._cameras.values(), (camera) => camera.destroy());
     this._cameras.clear();
diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts
index 1590b556..b08bff51 100644
--- a/src/card-controller/card-element-manager.ts
+++ b/src/card-controller/card-element-manager.ts
@@ -67,7 +67,12 @@ export class CardElementManager {
     this._api.getMediaLoadedInfoManager().initialize();
     this._api.getMicrophoneManager().initialize();
     this._api.getKeyboardStateManager().initialize();
+
+    // These initializers are called when the config is updated, but on initial
+    // creation of the card hass is not yet available when the config is first
+    // loaded.
     this._api.getDefaultManager().initialize();
+    this._api.getMediaPlayerManager().initialize();
 
     this._api
       .getHASSManager()
@@ -146,6 +151,8 @@ export class CardElementManager {
     // reconnection, to ensure the state subscription/unsubscription works
     // correctly for triggers.
     this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
+    this._api.getCameraManager().reset();
+
     this._element.removeEventListener(
       'mousemove',
       this._api.getInteractionManager().reportInteraction,
diff --git a/src/card-controller/config/config-manager.ts b/src/card-controller/config/config-manager.ts
index 28be50a8..7471f1e6 100644
--- a/src/card-controller/config/config-manager.ts
+++ b/src/card-controller/config/config-manager.ts
@@ -92,16 +92,20 @@ export class ConfigManager {
       camera: undefined,
     });
     this._api.getMediaLoadedInfoManager().clear();
+
+    this._api.getInitializationManager().uninitialize(InitializationAspect.VIEW);
     this._api.getViewManager().reset();
+
     this._api.getMessageManager().reset();
     this._api.getStyleManager().setPerformance();
-    this._api.getCardElementManager().update();
     this._api.getStatusBarItemManager().removeAllDynamicStatusBarItems();
 
     setKeyboardShortcutsFromConfig(this._api, this);
     setAutomationsFromConfig(this._api);
 
     this.computeOverrideConfig();
+
+    this._api.getCardElementManager().update();
   }
 
   public computeOverrideConfig(): void {
@@ -144,17 +148,18 @@ export class ConfigManager {
         .uninitialize(InitializationAspect.MICROPHONE_CONNECT);
     }
 
-    if (
-      previousConfig &&
-      !isEqual(
-        previousConfig?.view.default_reset,
-        this._overriddenConfig?.view.default_reset,
-      )
-    ) {
-      this._api
-        .getInitializationManager()
-        .uninitialize(InitializationAspect.DEFAULT_RESET);
-    }
+    /* async */ this._initializeBackground(previousConfig);
+  }
+
+  /**
+   * Initialize config dependent items in the background. For items that the
+   * card hard requires, use InitializationManager instead.
+   */
+  protected async _initializeBackground(
+    previousConfig: FrigateCardConfig | null,
+  ): Promise<void> {
+    await this._api.getDefaultManager().initializeIfNecessary(previousConfig);
+    await this._api.getMediaPlayerManager().initializeIfNecessary(previousConfig);
 
     this._api.getCardElementManager().update();
   }
diff --git a/src/card-controller/default-manager.ts b/src/card-controller/default-manager.ts
index 60eb7383..6ba7a380 100644
--- a/src/card-controller/default-manager.ts
+++ b/src/card-controller/default-manager.ts
@@ -1,4 +1,5 @@
-import PQueue from 'p-queue';
+import { isEqual } from 'lodash-es';
+import { FrigateCardConfig } from '../config/types';
 import { createGeneralAction } from '../utils/action';
 import { isActionAllowedBasedOnInteractionState } from '../utils/interaction-mode';
 import { Timer } from '../utils/timer';
@@ -10,20 +11,45 @@ import { CardDefaultManagerAPI } from './types';
 export class DefaultManager {
   protected _timer = new Timer();
   protected _api: CardDefaultManagerAPI;
-  protected _initializationLimit = new PQueue({ concurrency: 1 });
 
   constructor(api: CardDefaultManagerAPI) {
     this._api = api;
   }
 
+  public async initializeIfNecessary(
+    previousConfig: FrigateCardConfig | null,
+  ): Promise<void> {
+    if (
+      !isEqual(
+        previousConfig?.view.default_reset,
+        this._api.getConfigManager().getConfig()?.view.default_reset,
+      )
+    ) {
+      await this.initialize();
+    }
+  }
+
   /**
-   * Initialize the default manager. Requires both hass and configuration to be
-   * effective (so cannot be called from just the configuration manager, as hass
-   * will not be available yet)
+   * This needs to be public since the first initialization requires both hass
+   * and the config, so it is not suitable from calling exclusively from the
+   * config manager.
    */
   public async initialize(): Promise<boolean> {
-    const result = await this._initializationLimit.add(() => this._reconfigure());
-    this._startTimer();
+    this.uninitialize();
+
+    const config = this._api.getConfigManager().getConfig()?.view.default_reset;
+    if (config?.entities.length) {
+      this._api
+        .getHASSManager()
+        .getStateWatcher()
+        .subscribe(this._stateChangeHandler, config.entities);
+    }
+
+    const timerSeconds = this._api.getConfigManager().getConfig()?.view
+      .default_reset.every_seconds;
+    if (timerSeconds) {
+      this._timer.startRepeated(timerSeconds, () => this._setToDefaultIfAllowed());
+    }
 
     if (this._api.getConfigManager().getConfig()?.view.default_reset.after_interaction) {
       this._api.getAutomationsManager().addAutomations([
@@ -40,7 +66,7 @@ export class DefaultManager {
       ]);
     }
 
-    return !!result;
+    return true;
   }
 
   public uninitialize(): void {
@@ -49,28 +75,6 @@ export class DefaultManager {
     this._api.getAutomationsManager().deleteAutomations(this);
   }
 
-  protected async _reconfigure(): Promise<boolean> {
-    const hass = this._api.getHASSManager().getHASS();
-    const config = this._api.getConfigManager().getConfig()?.view.default_reset;
-    if (!hass || !config) {
-      return false;
-    }
-
-    this._api.getHASSManager().getStateWatcher().unsubscribe(this._stateChangeHandler);
-    this._api
-      .getHASSManager()
-      .getStateWatcher()
-      .subscribe(this._stateChangeHandler, config.entities);
-
-    // If the timer is running, restart it with the newly configured timer.
-    if (this._timer.isRunning()) {
-      this._timer.stop();
-      this._startTimer();
-    }
-
-    return true;
-  }
-
   protected _stateChangeHandler = (): void => {
     this._setToDefaultIfAllowed();
   };
@@ -92,12 +96,4 @@ export class DefaultManager {
       )
     );
   }
-
-  protected _startTimer(): void {
-    const timerSeconds = this._api.getConfigManager().getConfig()?.view
-      .default_reset.every_seconds;
-    if (timerSeconds) {
-      this._timer.startRepeated(timerSeconds, () => this._setToDefaultIfAllowed());
-    }
-  }
 }
diff --git a/src/card-controller/initialization-manager.ts b/src/card-controller/initialization-manager.ts
index 7b5a78da..127f69bb 100644
--- a/src/card-controller/initialization-manager.ts
+++ b/src/card-controller/initialization-manager.ts
@@ -1,3 +1,4 @@
+import PQueue from 'p-queue';
 import { loadLanguages } from '../localize/localize';
 import { sideLoadHomeAssistantElements } from '../utils/ha';
 import { Initializer } from '../utils/initializer/initializer';
@@ -6,14 +7,29 @@ import { CardInitializerAPI } from './types';
 export enum InitializationAspect {
   LANGUAGES = 'languages',
   SIDE_LOAD_ELEMENTS = 'side-load-elements',
-  MEDIA_PLAYERS = 'media-players',
   CAMERAS = 'cameras',
   MICROPHONE_CONNECT = 'microphone-connect',
-  DEFAULT_RESET = 'default-reset',
+  VIEW = 'view',
 }
 
+// =========================================================================
+// Rules for initialization. Initializers must be reentrant as these situations
+// may occur:
+//
+// 1. Multiple JS async contexts may execute these functions at the same time.
+// 2. At any point, something may uninitialize a part of the card (including
+//    while a different async context is in the middle of running the
+//    initialization method).
+// =========================================================================
+
 export class InitializationManager {
   protected _api: CardInitializerAPI;
+
+  // A concurrency limit is placed to ensure that on card load multiple async
+  // contexts do not attempt to initialize the card at the same time. This is
+  // not strictly necessary, just more efficient, as long as the "Rules for
+  // initialization" (above) are followed.
+  protected _initializationQueue = new PQueue({ concurrency: 1 });
   protected _initializer: Initializer;
 
   constructor(api: CardInitializerAPI, initializer?: Initializer) {
@@ -27,28 +43,29 @@ export class InitializationManager {
       return false;
     }
 
-    return (
-      this._initializer.isInitializedMultiple([
-        InitializationAspect.LANGUAGES,
-        InitializationAspect.SIDE_LOAD_ELEMENTS,
-        InitializationAspect.CAMERAS,
-        ...(config.live.microphone.always_connected
-          ? [InitializationAspect.MICROPHONE_CONNECT]
-          : []),
-      ]) &&
-      // If there's no view, re-initialize (e.g. config changes).
-      this._api.getViewManager().hasView()
-    );
+    return this._initializer.isInitializedMultiple([
+      InitializationAspect.LANGUAGES,
+      InitializationAspect.SIDE_LOAD_ELEMENTS,
+      InitializationAspect.CAMERAS,
+      ...(config.live.microphone.always_connected
+        ? [InitializationAspect.MICROPHONE_CONNECT]
+        : []),
+      InitializationAspect.VIEW,
+    ]);
   }
 
   /**
    * Initialize the hard requirements for rendering anything.
    * @returns `true` if card rendering can continue.
    */
-  public async initializeMandatory(): Promise<boolean> {
+  public async initializeMandatory(): Promise<void> {
+    await this._initializationQueue.add(() => this._initializeMandatory());
+  }
+
+  protected async _initializeMandatory(): Promise<void> {
     const hass = this._api.getHASSManager().getHASS();
-    if (!hass) {
-      return false;
+    if (!hass || this.isInitializedMandatory()) {
+      return;
     }
 
     if (
@@ -60,12 +77,12 @@ export class InitializationManager {
           await sideLoadHomeAssistantElements(),
       }))
     ) {
-      return false;
+      return;
     }
 
     const config = this._api.getConfigManager().getConfig();
     if (!config) {
-      return false;
+      return;
     }
 
     if (
@@ -83,76 +100,23 @@ export class InitializationManager {
         }),
       }))
     ) {
-      return false;
-    }
-
-    if (!this._api.getMessageManager().hasMessage()) {
-      if (!this._api.getViewManager().hasView()) {
-        // Set a view on initial load. However, if the query string contains a
-        // view related action, we don't set any view here and allow that content
-        // to be triggered by the firstUpdated() call that runs query string
-        // actions. To do otherwise may cause a race condition between the default
-        // view and the querystring view, see:
-        // https://github.com/dermotduffy/frigate-hass-card/issues/1200
-        const hasViewRelatedActions = this._api
-          .getQueryStringManager()
-          .hasViewRelatedActions();
-        if (hasViewRelatedActions) {
-          this._api.getQueryStringManager().executeViewRelated();
-        } else {
-          this._api.getViewManager().setViewDefaultWithNewQuery({ failSafe: true });
-        }
-      } else {
-        // If we already have a view, something (e.g. cameras) may have been
-        // reinitialized, be sure to ask for an update.
-        this._api.getCardElementManager().update();
-      }
-    }
-
-    return true;
-  }
-
-  /**
-   * Initialize aspects of the card that can load in the 'background'.
-   * @returns `true` if card rendering can continue.
-   */
-  public async initializeBackgroundIfNecessary(): Promise<boolean> {
-    const hass = this._api.getHASSManager().getHASS();
-    const config = this._api.getConfigManager().getConfig();
-
-    if (!hass || !config) {
-      return false;
-    }
-
-    if (
-      this._initializer.isInitializedMultiple([
-        InitializationAspect.DEFAULT_RESET,
-        ...(config.menu.buttons.media_player.enabled
-          ? [InitializationAspect.MEDIA_PLAYERS]
-          : []),
-      ])
-    ) {
-      return true;
+      return;
     }
 
     if (
-      !(await this._initializer.initializeMultipleIfNecessary({
-        [InitializationAspect.DEFAULT_RESET]: async () =>
-          await this._api.getDefaultManager().initialize(),
-        ...(config.menu.buttons.media_player.enabled && {
-          [InitializationAspect.MEDIA_PLAYERS]: async () =>
-            await this._api.getMediaPlayerManager().initialize(),
-        }),
-      }))
+      this._api.getMessageManager().hasMessage() ||
+      !(await this._initializer.initializeIfNecessary(
+        InitializationAspect.VIEW,
+        this._api.getViewManager().initialize,
+      ))
     ) {
-      return false;
+      return;
     }
 
     this._api.getCardElementManager().update();
-    return true;
   }
 
-  public uninitialize(aspect: InitializationAspect) {
-    return this._initializer.uninitialize(aspect);
+  public uninitialize(aspect: InitializationAspect): void {
+    this._initializer.uninitialize(aspect);
   }
 }
diff --git a/src/card-controller/media-player-manager.ts b/src/card-controller/media-player-manager.ts
index 2cda70be..ff26d201 100644
--- a/src/card-controller/media-player-manager.ts
+++ b/src/card-controller/media-player-manager.ts
@@ -1,4 +1,4 @@
-import { CameraConfig } from '../config/types';
+import { CameraConfig, FrigateCardConfig } from '../config/types';
 import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA } from '../const';
 import { localize } from '../localize/localize';
 import { errorToConsole } from '../utils/basic';
@@ -25,9 +25,23 @@ export class MediaPlayerManager {
     return this._mediaPlayers.length > 0;
   }
 
+  public async initializeIfNecessary(
+    previousConfig: FrigateCardConfig | null,
+  ): Promise<void> {
+    if (
+      previousConfig?.menu.buttons.media_player.enabled !==
+      this._api.getConfigManager().getConfig()?.menu.buttons.media_player.enabled
+    ) {
+      await this.initialize();
+    }
+  }
+
   public async initialize(): Promise<boolean> {
     const hass = this._api.getHASSManager().getHASS();
-    if (!hass) {
+    if (
+      !hass ||
+      !this._api.getConfigManager().getConfig()?.menu.buttons.media_player.enabled
+    ) {
       return false;
     }
 
diff --git a/src/card-controller/query-string-manager.ts b/src/card-controller/query-string-manager.ts
index 9009bb67..ee5c9569 100644
--- a/src/card-controller/query-string-manager.ts
+++ b/src/card-controller/query-string-manager.ts
@@ -23,18 +23,18 @@ export class QueryStringManager {
     return !!this._calculateIntent().view;
   }
 
-  public executeNonViewRelated = (): void => {
-    this._executeNonViewRelated(this._calculateIntent());
+  public executeNonViewRelated = async (): Promise<void> => {
+    await this._executeNonViewRelated(this._calculateIntent());
   };
 
-  public executeViewRelated = (): void => {
-    this._executeViewRelated(this._calculateIntent());
+  public executeViewRelated = async (): Promise<void> => {
+    await this._executeViewRelated(this._calculateIntent());
   };
 
-  public executeAll = (): void => {
+  public executeAll = async (): Promise<void> => {
     const intent = this._calculateIntent();
-    this._executeViewRelated(intent);
-    this._executeNonViewRelated(intent);
+    await this._executeViewRelated(intent);
+    await this._executeNonViewRelated(intent);
   };
 
   protected async _executeViewRelated(intent: QueryStringViewIntent): Promise<void> {
@@ -62,7 +62,7 @@ export class QueryStringManager {
     }
   }
 
-  protected _executeNonViewRelated(intent: QueryStringViewIntent): void {
+  protected async _executeNonViewRelated(intent: QueryStringViewIntent): Promise<void> {
     if (
       // Only execute non-view actions when the card has rendered at least once.
       !this._api.getCardElementManager().hasUpdated() ||
@@ -71,7 +71,7 @@ export class QueryStringManager {
       return;
     }
 
-    this._api.getActionsManager().executeActions(intent.other);
+    await this._api.getActionsManager().executeActions(intent.other);
   }
 
   protected _calculateIntent(): QueryStringViewIntent {
diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts
index 834d7f8a..41884bb5 100644
--- a/src/card-controller/types.ts
+++ b/src/card-controller/types.ts
@@ -91,6 +91,7 @@ export interface CardConfigAPI {
   getDefaultManager(): DefaultManager;
   getInitializationManager(): InitializationManager;
   getMediaLoadedInfoManager(): MediaLoadedInfoManager;
+  getMediaPlayerManager(): MediaPlayerManager;
   getMessageManager(): MessageManager;
   getStatusBarItemManager(): StatusBarItemManager;
   getStyleManager(): StyleManager;
@@ -201,6 +202,7 @@ export interface CardMediaLoadedAPI {
 
 export interface CardMediaPlayerAPI {
   getCameraManager(): CameraManager;
+  getConfigManager(): ConfigManager;
   getEntityRegistryManager(): EntityRegistryManager;
   getHASSManager(): HASSManager;
   getMessageManager(): MessageManager;
@@ -257,6 +259,7 @@ export interface CardViewAPI {
   getHASSManager(): HASSManager;
   getMediaLoadedInfoManager(): MediaLoadedInfoManager;
   getMessageManager(): MessageManager;
+  getQueryStringManager(): QueryStringManager;
   getStyleManager(): StyleManager;
   getTriggersManager(): TriggersManager;
 }
diff --git a/src/card-controller/view/view-manager.ts b/src/card-controller/view/view-manager.ts
index a3718a75..6fcff9a1 100644
--- a/src/card-controller/view/view-manager.ts
+++ b/src/card-controller/view/view-manager.ts
@@ -133,6 +133,24 @@ export class ViewManager implements ViewManagerInterface {
     );
   }
 
+  public initialize = async (): Promise<boolean> => {
+    // Set a view on initial load. However, if the query string contains a view
+    // related action, we don't set any view here and allow that content to be
+    // triggered by the firstUpdated() call that runs query string actions. To
+    // do otherwise may cause a race condition between the default view and the
+    // querystring view, see:
+    // https://github.com/dermotduffy/frigate-hass-card/issues/1200
+    const hasViewRelatedActions = this._api
+      .getQueryStringManager()
+      .hasViewRelatedActions();
+    if (hasViewRelatedActions) {
+      await this._api.getQueryStringManager().executeViewRelated();
+    } else {
+      await this.setViewDefaultWithNewQuery({ failSafe: true });
+    }
+    return true;
+  };
+
   protected _setView(view: View | null): void {
     const oldView = this._view;
 
diff --git a/src/card.ts b/src/card.ts
index 035974df..57321d83 100644
--- a/src/card.ts
+++ b/src/card.ts
@@ -15,7 +15,7 @@ import { FrigateCardElements } from './components/elements.js';
 import './components/menu.js';
 import { FrigateCardMenu } from './components/menu.js';
 import './components/message.js';
-import { renderMessage, renderProgressIndicator } from './components/message.js';
+import { renderMessage } from './components/message.js';
 import './components/overlay.js';
 import { FrigateCardOverlay } from './components/overlay.js';
 import './components/status-bar';
@@ -188,10 +188,6 @@ class FrigateCard extends LitElement {
     return true;
   }
 
-  protected willUpdate(): void {
-    this._controller.getInitializationManager().initializeBackgroundIfNecessary();
-  }
-
   protected _renderMenuStatusContainer(
     position: 'top' | 'bottom' | 'overlay',
   ): TemplateResult | void {
@@ -373,37 +369,32 @@ class FrigateCard extends LitElement {
         ${this._renderMenuStatusContainer('top')}
         <div ${ref(this._refMain)} class="${classMap(mainClasses)}">
           ${this._renderMenuStatusContainer('overlay')}
-          ${!cameraManager.isInitialized() &&
-          !this._controller.getMessageManager().hasMessage()
-            ? renderProgressIndicator({
-                cardWideConfig: this._controller.getConfigManager().getCardWideConfig(),
-              })
-            : // Always want to render <frigate-card-views> even if there's a message, to
-              // ensure live preload is always present (even if not displayed).
-              html`<frigate-card-views
-                ${ref(this._refViews)}
-                .hass=${this._hass}
-                .viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
-                .cameraManager=${cameraManager}
-                .resolvedMediaCache=${this._controller.getResolvedMediaCache()}
-                .nonOverriddenConfig=${this._controller
-                  .getConfigManager()
-                  .getNonOverriddenConfig()}
-                .overriddenConfig=${this._controller.getConfigManager().getConfig()}
-                .cardWideConfig=${this._controller
-                  .getConfigManager()
-                  .getCardWideConfig()}
-                .rawConfig=${this._controller.getConfigManager().getRawConfig()}
-                .configManager=${this._controller.getConfigManager()}
-                .conditionsManagerEpoch=${this._controller
-                  .getConditionsManager()
-                  ?.getEpoch()}
-                .hide=${!!this._controller.getMessageManager().hasMessage()}
-                .microphoneManager=${this._controller.getMicrophoneManager()}
-                .triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
-                  ? this._controller.getTriggersManager().getTriggeredCameraIDs()
-                  : undefined}
-              ></frigate-card-views>`}
+          ${
+            // Always want to render <frigate-card-views> even if there's a message, to
+            // ensure live preload is always present (even if not displayed).
+            html`<frigate-card-views
+              ${ref(this._refViews)}
+              .hass=${this._hass}
+              .viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
+              .cameraManager=${cameraManager}
+              .resolvedMediaCache=${this._controller.getResolvedMediaCache()}
+              .nonOverriddenConfig=${this._controller
+                .getConfigManager()
+                .getNonOverriddenConfig()}
+              .overriddenConfig=${this._controller.getConfigManager().getConfig()}
+              .cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()}
+              .rawConfig=${this._controller.getConfigManager().getRawConfig()}
+              .configManager=${this._controller.getConfigManager()}
+              .conditionsManagerEpoch=${this._controller
+                .getConditionsManager()
+                ?.getEpoch()}
+              .hide=${!!this._controller.getMessageManager().hasMessage()}
+              .microphoneManager=${this._controller.getMicrophoneManager()}
+              .triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
+                ? this._controller.getTriggersManager().getTriggeredCameraIDs()
+                : undefined}
+            ></frigate-card-views>`
+          }
           ${
             // Keep message rendering to last to show messages that may have been
             // generated during the render.
diff --git a/src/images/README.md b/src/images/README.md
index b65d2855..1c3f584c 100644
--- a/src/images/README.md
+++ b/src/images/README.md
@@ -21,3 +21,9 @@
 $ convert -extent 2048x1152 -flop -gravity center 47543120431_b285c45ac8_k.jpg frigate-bird-in-sky.jpg
 $ mogrify -strip -interlace Plane -quality 85% -scale 492x277 frigate-bird-in-sky.jpg
 ```
+
+## camera-iris.svg
+
+**Link**: https://pictogrammers.com/library/mdi/icon/camera-iris/
+
+**License**: https://www.apache.org/licenses/LICENSE-2.0
diff --git a/src/images/camera-iris.svg b/src/images/camera-iris.svg
new file mode 100644
index 00000000..dd0cbf5e
--- /dev/null
+++ b/src/images/camera-iris.svg
@@ -0,0 +1,1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13.73,15L9.83,21.76C10.53,21.91 11.25,22 12,22C14.4,22 16.6,21.15 18.32,19.75L14.66,13.4M2.46,15C3.38,17.92 5.61,20.26 8.45,21.34L12.12,15M8.54,12L4.64,5.25C3,7 2,9.39 2,12C2,12.68 2.07,13.35 2.2,14H9.69M21.8,10H14.31L14.6,10.5L19.36,18.75C21,16.97 22,14.6 22,12C22,11.31 21.93,10.64 21.8,10M21.54,9C20.62,6.07 18.39,3.74 15.55,2.66L11.88,9M9.4,10.5L14.17,2.24C13.47,2.09 12.75,2 12,2C9.6,2 7.4,2.84 5.68,4.25L9.34,10.6L9.4,10.5Z" /></svg>
\ No newline at end of file
diff --git a/src/types.ts b/src/types.ts
index 990deea5..447447e9 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,6 +1,8 @@
 import {
   HomeAssistant,
+  LovelaceCard,
   LovelaceCardConfig,
+  LovelaceCardEditor,
   Themes,
 } from '@dermotduffy/custom-card-helpers';
 import { StyleInfo } from 'lit/directives/style-map.js';
@@ -86,12 +88,14 @@ export interface FrigateCardMediaPlayer {
   isPaused(): boolean;
 }
 
+export type LovelaceCardWithEditor = LovelaceCard & {
+  constructor: {
+    getConfigElement(): Promise<LovelaceCardEditor>;
+  };
+};
+
 export interface CardHelpers {
-  createCardElement(config: LovelaceCardConfig): Promise<{
-    constructor: {
-      getConfigElement(): HTMLElement;
-    };
-  }>;
+  createCardElement(config: LovelaceCardConfig): Promise<LovelaceCardWithEditor>;
 }
 
 export type PTZMovementType = 'relative' | 'continuous';
diff --git a/src/utils/ha/index.ts b/src/utils/ha/index.ts
index bd0d5ec2..3da4cece 100644
--- a/src/utils/ha/index.ts
+++ b/src/utils/ha/index.ts
@@ -11,6 +11,7 @@ import {
   CardHelpers,
   ExtendedHomeAssistant,
   FrigateCardError,
+  LovelaceCardWithEditor,
   SignedPath,
   signedPathSchema,
   StateParameters,
@@ -316,17 +317,21 @@ export const sideLoadHomeAssistantElements = async (): Promise<boolean> => {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   const helpers: CardHelpers = await (window as any).loadCardHelpers();
 
-  // The picture-glance editor loads everything this card needs.
-  const pictureGlance = await helpers.createCardElement({
+  // This bizarre combination of hacks creates a dummy picture glance card, then
+  // waits for it to be fully loaded/upgraded as a custom element, so it will
+  // have the getConfigElement() method which is necessary to load all the
+  // elements this card requires.
+  await helpers.createCardElement({
     type: 'picture-glance',
     entities: [],
     camera_image: 'dummy-to-load-editor-components',
   });
-  if (pictureGlance.constructor.getConfigElement) {
-    await pictureGlance.constructor.getConfigElement();
-    return true;
-  }
-  return false;
+
+  const pgcConstructor = await customElements.whenDefined('hui-picture-glance-card');
+  const pgc = new pgcConstructor() as LovelaceCardWithEditor;
+
+  await pgc.constructor.getConfigElement();
+  return true;
 };
 
 /**
diff --git a/src/utils/initializer/initializer.ts b/src/utils/initializer/initializer.ts
index 3ab033a9..2a44a5df 100644
--- a/src/utils/initializer/initializer.ts
+++ b/src/utils/initializer/initializer.ts
@@ -1,21 +1,14 @@
 import { allPromises } from '../basic';
 
-enum InitializationState {
-  INITIALIZING = 'initializing',
-  INITIALIZED = 'initialized',
-}
-
 type InitializationCallback = () => Promise<boolean>;
 
 /**
- * Manages initialization state & calling initializers.
+ * Manages initialization state & calling initializers. There is no guarantee
+ * something will not be initialized twice unless there are concurrency controls
+ * applied to the usage of this class.
  */
 export class Initializer {
-  protected _state: Map<string, InitializationState>;
-
-  constructor() {
-    this._state = new Map();
-  }
+  protected _initialized: Set<string> = new Set();
 
   public async initializeMultipleIfNecessary(
     aspects: Record<string, InitializationCallback>,
@@ -27,43 +20,30 @@ export class Initializer {
     return results.every(Boolean);
   }
 
-  /**
-   *
-   * @param aspect The aspect to initialize.
-   * @param initializer The initializer to call.
-   * @returns `true` if the state is confirmed as initialized, `false`
-   * otherwise (i.e. initializing).
-   */
   public async initializeIfNecessary(
     aspect: string,
     initializer?: InitializationCallback,
   ): Promise<boolean> {
-    const state = this._state.get(aspect);
-    if (state !== InitializationState.INITIALIZED) {
-      if (state !== InitializationState.INITIALIZING) {
-        if (initializer) {
-          this._state.set(aspect, InitializationState.INITIALIZING);
-          if (await initializer()) {
-            this._state.set(aspect, InitializationState.INITIALIZED);
-          } else {
-            this.uninitialize(aspect);
-          }
-        } else {
-          this._state.set(aspect, InitializationState.INITIALIZED);
-        }
-        return true;
-      }
-      return false;
+    if (this._initialized.has(aspect)) {
+      return true;
+    }
+    if (!initializer) {
+      this._initialized.add(aspect);
+      return true;
+    }
+    if (await initializer()) {
+      this._initialized.add(aspect);
+      return true;
     }
-    return true;
+    return false;
   }
 
-  public uninitialize(aspect: string) {
-    return this._state.delete(aspect);
+  public uninitialize(aspect: string): void {
+    this._initialized.delete(aspect);
   }
 
   public isInitialized(aspect: string): boolean {
-    return this._state.get(aspect) == InitializationState.INITIALIZED;
+    return this._initialized.has(aspect);
   }
 
   public isInitializedMultiple(aspects: string[]): boolean {
diff --git a/vite.config.ts b/vite.config.ts
index bbc3c8e5..f5ddd5f1 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -27,6 +27,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [
   'utils/endpoint.ts',
   'utils/ha/entity-registry/types.ts',
   'utils/ha/types.ts',
+  'utils/initializer.ts',
   'utils/interaction-mode.ts',
   'utils/media-info.ts',
   'utils/media-layout.ts',


Test patch:
diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts
index 39e2e8f1..6d30c615 100644
--- a/tests/camera-manager/manager.test.ts
+++ b/tests/camera-manager/manager.test.ts
@@ -1289,4 +1289,19 @@ describe('CameraManager', async () => {
       expect(await manager.getMediaSeekTime(media, middleTime)).toBeNull();
     });
   });
+
+  it('should reset', async () => {
+    const api = createCardAPI();
+    const engine = mock<CameraManagerEngine>();
+    vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
+    const manager = createCameraManager(api, engine);
+
+    expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
+
+    expect(manager.getStore().getCameraCount()).toBe(1);
+
+    await manager.reset();
+
+    expect(manager.getStore().getCameraCount()).toBe(0);
+  });
 });
diff --git a/tests/camera-manager/store.test.ts b/tests/camera-manager/store.test.ts
index 13daec36..f0184b83 100644
--- a/tests/camera-manager/store.test.ts
+++ b/tests/camera-manager/store.test.ts
@@ -308,4 +308,39 @@ describe('CameraManagerStore', async () => {
     );
     expect(store.getCameraIDsWithCapability('clips')).toEqual(new Set(['one']));
   });
+
+  it('setCameras', async () => {
+    const store = new CameraManagerStore();
+    const camera_1 = new Camera(createCameraConfig({ id: 'camera-1' }), engineGeneric);
+    camera_1.destroy = vi.fn();
+
+    const camera_2 = new Camera(createCameraConfig({ id: 'camera-2' }), engineGeneric);
+    camera_2.destroy = vi.fn();
+
+    const camera_3 = new Camera(createCameraConfig({ id: 'camera-3' }), engineGeneric);
+    camera_3.destroy = vi.fn();
+
+    const camera_3_new = new Camera(
+      createCameraConfig({ id: 'camera-3' }),
+      engineGeneric,
+    );
+    camera_3_new.destroy = vi.fn();
+
+    const camera_4 = new Camera(createCameraConfig({ id: 'camera-4' }), engineGeneric);
+    camera_4.destroy = vi.fn();
+
+    await store.setCameras([camera_1, camera_2, camera_3]);
+    await store.setCameras([camera_2, camera_3_new, camera_4]);
+
+    expect(store.getCamera('camera-1')).toBeNull();
+    expect(store.getCamera('camera-2')).toBe(camera_2);
+    expect(store.getCamera('camera-3')).toBe(camera_3_new);
+    expect(store.getCamera('camera-4')).toBe(camera_4);
+
+    expect(camera_1.destroy).toBeCalled();
+    expect(camera_2.destroy).not.toBeCalled();
+    expect(camera_3.destroy).toBeCalled();
+    expect(camera_3_new.destroy).not.toBeCalled();
+    expect(camera_4.destroy).not.toBeCalled();
+  });
 });
diff --git a/tests/card-controller/config/config-manager.test.ts b/tests/card-controller/config/config-manager.test.ts
index 2272b86e..90b1b42b 100644
--- a/tests/card-controller/config/config-manager.test.ts
+++ b/tests/card-controller/config/config-manager.test.ts
@@ -1,10 +1,10 @@
 import { beforeEach, describe, expect, it, vi } from 'vitest';
 import { ZodError } from 'zod';
-import { frigateCardConfigSchema } from '../../../src/config/types';
 import { getOverriddenConfig } from '../../../src/card-controller/conditions-manager';
 import { ConfigManager } from '../../../src/card-controller/config/config-manager';
 import { InitializationAspect } from '../../../src/card-controller/initialization-manager';
-import { createCardAPI, createConfig } from '../../test-utils';
+import { frigateCardConfigSchema } from '../../../src/config/types';
+import { createCardAPI, createConfig, flushPromises } from '../../test-utils';
 
 vi.mock('../../../src/card-controller/conditions-manager.js');
 
@@ -229,7 +229,9 @@ describe('ConfigManager', () => {
       vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_1));
 
       manager.setConfig(config_1);
-      expect(api.getInitializationManager().uninitialize).not.toBeCalled();
+      expect(api.getInitializationManager().uninitialize).toHaveBeenLastCalledWith(
+        InitializationAspect.VIEW,
+      );
 
       const config_2 = {
         type: 'custom:frigate-card',
@@ -238,7 +240,7 @@ describe('ConfigManager', () => {
       vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_2));
       manager.computeOverrideConfig();
 
-      expect(api.getInitializationManager().uninitialize).toBeCalledWith(
+      expect(api.getInitializationManager().uninitialize).toHaveBeenLastCalledWith(
         InitializationAspect.CAMERAS,
       );
     });
@@ -253,7 +255,9 @@ describe('ConfigManager', () => {
       vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_1));
 
       manager.setConfig(config_1);
-      expect(api.getInitializationManager().uninitialize).not.toBeCalled();
+      expect(api.getInitializationManager().uninitialize).toHaveBeenLastCalledWith(
+        InitializationAspect.VIEW,
+      );
 
       const config_2 = {
         ...config_1,
@@ -264,7 +268,7 @@ describe('ConfigManager', () => {
       vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_2));
       manager.computeOverrideConfig();
 
-      expect(api.getInitializationManager().uninitialize).toBeCalledWith(
+      expect(api.getInitializationManager().uninitialize).toHaveBeenLastCalledWith(
         InitializationAspect.CAMERAS,
       );
     });
@@ -284,7 +288,9 @@ describe('ConfigManager', () => {
       vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_1));
 
       manager.setConfig(config_1);
-      expect(api.getInitializationManager().uninitialize).not.toBeCalled();
+      expect(api.getInitializationManager().uninitialize).toHaveBeenLastCalledWith(
+        InitializationAspect.VIEW,
+      );
 
       const config_2 = {
         ...config_1,
@@ -297,42 +303,26 @@ describe('ConfigManager', () => {
       vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_2));
       manager.computeOverrideConfig();
 
-      expect(api.getInitializationManager().uninitialize).toBeCalledWith(
+      expect(api.getInitializationManager().uninitialize).toHaveBeenLastCalledWith(
         InitializationAspect.MICROPHONE_CONNECT,
       );
     });
+  });
 
-    it('view.default_reset', () => {
-      const api = createCardAPI();
-      const manager = new ConfigManager(api);
-      const config_1 = {
-        type: 'custom:frigate-card',
-        cameras: [{ camera_entity: 'camera.office' }],
-        view: {
-          default_reset: {
-            every_seconds: 1,
-          },
-        },
-      };
-      vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_1));
+  it('should initialize background items', async () => {
+    const api = createCardAPI();
+    const manager = new ConfigManager(api);
+    const config = {
+      type: 'custom:frigate-card',
+      cameras: [{ camera_entity: 'camera.office' }],
+    };
+    vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config));
 
-      manager.setConfig(config_1);
-      expect(api.getInitializationManager().uninitialize).not.toBeCalled();
+    manager.setConfig(config);
 
-      const config_2 = {
-        ...config_1,
-        view: {
-          default_reset: {
-            every_seconds: 2,
-          },
-        },
-      };
-      vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_2));
-      manager.computeOverrideConfig();
+    await flushPromises();
 
-      expect(api.getInitializationManager().uninitialize).toBeCalledWith(
-        InitializationAspect.DEFAULT_RESET,
-      );
-    });
+    expect(api.getDefaultManager().initializeIfNecessary).toBeCalledWith(null);
+    expect(api.getMediaPlayerManager().initializeIfNecessary).toBeCalledWith(null);
   });
 });
diff --git a/tests/card-controller/default-manager.test.ts b/tests/card-controller/default-manager.test.ts
index e266f66f..ab4b4748 100644
--- a/tests/card-controller/default-manager.test.ts
+++ b/tests/card-controller/default-manager.test.ts
@@ -28,6 +28,7 @@ describe('DefaultManager', () => {
   describe('time based', () => {
     it('should set default view when allowed', async () => {
       const api = createCardAPIWithStateWatcher();
+      vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
       vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
         createConfig({
           view: {
@@ -62,7 +63,8 @@ describe('DefaultManager', () => {
     });
 
     it('should not set default view when not configured', () => {
-      const api = createCardAPI();
+      const api = createCardAPIWithStateWatcher();
+      vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
       vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
         createConfig({
           view: {
@@ -88,6 +90,7 @@ describe('DefaultManager', () => {
 
     it('should restart timer when reconfigured', async () => {
       const api = createCardAPIWithStateWatcher();
+      vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
       vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
         createConfig({
           view: {
@@ -99,9 +102,6 @@ describe('DefaultManager', () => {
       );
       vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false);
 
-      const hass = createHASS();
-      vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
-
       vi.useFakeTimers();
 
       const manager = new DefaultManager(api);
@@ -120,6 +120,7 @@ describe('DefaultManager', () => {
 
   it('should set default view when state changed', async () => {
     const api = createCardAPIWithStateWatcher();
+    vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
     vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
       createConfig({
         view: {
@@ -210,4 +211,47 @@ describe('DefaultManager', () => {
       expect(api.getAutomationsManager().deleteAutomations).toBeCalledWith(manager);
     });
   });
+
+  it('should reinitialize when there is a config change', async () => {
+    const configOn = createConfig({
+      view: {
+        default_reset: {
+          every_seconds: 10,
+        },
+      },
+    });
+    const configOff = createConfig({
+      view: {
+        default_reset: {
+          every_seconds: 0,
+        },
+      },
+    });
+
+    const api = createCardAPIWithStateWatcher();
+    vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
+    vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false);
+
+    vi.useFakeTimers();
+
+    const manager = new DefaultManager(api);
+
+    vi.mocked(api.getConfigManager().getConfig).mockReturnValue(configOn);
+    await manager.initializeIfNecessary(null);
+
+    vi.runOnlyPendingTimers();
+    expect(api.getViewManager().setViewDefault).toBeCalledTimes(1);
+
+    vi.mocked(api.getConfigManager().getConfig).mockReturnValue(configOff);
+    await manager.initializeIfNecessary(configOn);
+
+    vi.runOnlyPendingTimers();
+    expect(api.getViewManager().setViewDefault).toBeCalledTimes(1);
+
+    vi.mocked(api.getConfigManager().getConfig).mockReturnValue(configOff);
+    await manager.initializeIfNecessary(configOff);
+
+    vi.runOnlyPendingTimers();
+    expect(api.getViewManager().setViewDefault).toBeCalledTimes(1);
+  });
 });
diff --git a/tests/card-controller/initialization-manager.test.ts b/tests/card-controller/initialization-manager.test.ts
index add474ce..628cf94e 100644
--- a/tests/card-controller/initialization-manager.test.ts
+++ b/tests/card-controller/initialization-manager.test.ts
@@ -21,50 +21,23 @@ describe('InitializationManager', () => {
   describe('should correctly determine when mandatory initialization is required', () => {
     it('without config', () => {
       const api = createCardAPI();
-      const initializer = mock<Initializer>();
-      const manager = new InitializationManager(api, initializer);
+      const manager = new InitializationManager(api);
 
       expect(manager.isInitializedMandatory()).toBeFalsy();
     });
 
     it('without aspects', () => {
       const api = createCardAPI();
-      const initializer = mock<Initializer>();
-      const manager = new InitializationManager(api, initializer);
-
-      vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
-      initializer.isInitializedMultiple.mockReturnValue(false);
-
-      expect(manager.isInitializedMandatory()).toBeFalsy();
-    });
-
-    it('without view', () => {
-      const api = createCardAPI();
-      const initializer = mock<Initializer>();
-      const manager = new InitializationManager(api, initializer);
+      const manager = new InitializationManager(api);
 
       vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
-      initializer.isInitializedMultiple.mockReturnValue(true);
 
       expect(manager.isInitializedMandatory()).toBeFalsy();
     });
 
-    it('with aspects and view', () => {
-      const api = createCardAPI();
-      const initializer = mock<Initializer>();
-      const manager = new InitializationManager(api, initializer);
-
-      vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
-      initializer.isInitializedMultiple.mockReturnValue(true);
-      vi.mocked(api.getViewManager().hasView).mockReturnValue(true);
-
-      expect(manager.isInitializedMandatory()).toBeTruthy();
-    });
-
     it('with microphone if configured', () => {
       const api = createCardAPI();
-      const initializer = mock<Initializer>();
-      const manager = new InitializationManager(api, initializer);
+      const manager = new InitializationManager(api);
 
       vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
         createConfig({
@@ -75,24 +48,25 @@ describe('InitializationManager', () => {
           },
         }),
       );
-      initializer.isInitializedMultiple.mockReturnValue(true);
-      vi.mocked(api.getViewManager().hasView).mockReturnValue(true);
 
-      expect(manager.isInitializedMandatory()).toBeTruthy();
+      expect(manager.isInitializedMandatory()).toBeFalsy();
     });
   });
 
   describe('should initialize mandatory', () => {
     it('without hass', async () => {
       const manager = new InitializationManager(createCardAPI());
-      expect(await manager.initializeMandatory()).toBeFalsy();
+      await manager.initializeMandatory();
     });
 
     it('without config', async () => {
       const api = createCardAPI();
       const manager = new InitializationManager(api);
       vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
-      expect(await manager.initializeMandatory()).toBeFalsy();
+      vi.mocked(loadLanguages).mockResolvedValue(true);
+      vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue(true);
+
+      await manager.initializeMandatory();
     });
 
     it('successfully', async () => {
@@ -103,15 +77,23 @@ describe('InitializationManager', () => {
       vi.mocked(api.getQueryStringManager().hasViewRelatedActions).mockReturnValue(
         false,
       );
+      vi.mocked(loadLanguages).mockResolvedValue(true);
+      vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue(true);
+      vi.mocked(api.getCameraManager().initializeCamerasFromConfig).mockResolvedValue(
+        true,
+      );
+      vi.mocked(api.getViewManager().initialize).mockResolvedValue(true);
+
       const manager = new InitializationManager(api);
 
-      expect(await manager.initializeMandatory()).toBeTruthy();
+      await manager.initializeMandatory();
 
       expect(loadLanguages).toBeCalled();
       expect(sideLoadHomeAssistantElements).toBeCalled();
       expect(api.getCameraManager().initializeCamerasFromConfig).toBeCalled();
-      expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
+      expect(api.getViewManager().initialize).toBeCalled();
       expect(api.getMicrophoneManager().connect).not.toBeCalled();
+      expect(api.getCardElementManager().update).toBeCalled();
     });
 
     it('successfully with microphone if configured', async () => {
@@ -126,23 +108,17 @@ describe('InitializationManager', () => {
           },
         }),
       );
-      const manager = new InitializationManager(api);
-
-      expect(await manager.initializeMandatory()).toBeTruthy();
-      expect(api.getMicrophoneManager().connect).toBeCalled();
-    });
+      vi.mocked(loadLanguages).mockResolvedValue(true);
+      vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue(true);
+      vi.mocked(api.getCameraManager().initializeCamerasFromConfig).mockResolvedValue(
+        true,
+      );
 
-    it('successfully with querystring view', async () => {
-      const api = createCardAPI();
-      vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
-      vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
-      vi.mocked(api.getMessageManager().hasMessage).mockReturnValue(false);
-      vi.mocked(api.getQueryStringManager().hasViewRelatedActions).mockReturnValue(true);
       const manager = new InitializationManager(api);
 
-      expect(await manager.initializeMandatory()).toBeTruthy();
+      await manager.initializeMandatory();
 
-      expect(api.getQueryStringManager().executeViewRelated).toBeCalled();
+      expect(api.getMicrophoneManager().connect).toBeCalled();
     });
 
     it('with message set during initialization', async () => {
@@ -153,12 +129,17 @@ describe('InitializationManager', () => {
       vi.mocked(api.getQueryStringManager().hasViewRelatedActions).mockReturnValue(
         false,
       );
+      vi.mocked(loadLanguages).mockResolvedValue(true);
+      vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue(true);
+      vi.mocked(api.getCameraManager().initializeCamerasFromConfig).mockResolvedValue(
+        true,
+      );
+
       const manager = new InitializationManager(api);
 
-      expect(await manager.initializeMandatory()).toBeTruthy();
+      await manager.initializeMandatory();
 
-      expect(api.getViewManager().setViewByParameters).not.toBeCalled();
-      expect(api.getViewManager().setViewDefault).not.toBeCalled();
+      expect(api.getViewManager().initialize).not.toBeCalled();
     });
 
     it('with languages and side load elements in progress', async () => {
@@ -168,7 +149,7 @@ describe('InitializationManager', () => {
       const manager = new InitializationManager(api, initializer);
       initializer.initializeMultipleIfNecessary.mockResolvedValue(false);
 
-      expect(await manager.initializeMandatory()).toBeFalsy();
+      await manager.initializeMandatory();
     });
 
     it('with cameras in progress', async () => {
@@ -182,100 +163,7 @@ describe('InitializationManager', () => {
         .mockResolvedValueOnce(true)
         .mockResolvedValueOnce(false);
 
-      expect(await manager.initializeMandatory()).toBeFalsy();
-    });
-
-    it('with existing view', async () => {
-      const api = createCardAPI();
-      vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
-      vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
-      vi.mocked(api.getViewManager().hasView).mockReturnValue(true);
-
-      const initializer = mock<Initializer>();
-      const manager = new InitializationManager(api, initializer);
-      initializer.initializeMultipleIfNecessary
-        .mockResolvedValueOnce(true)
-        .mockResolvedValueOnce(true);
-
-      expect(await manager.initializeMandatory()).toBeTruthy();
-      expect(api.getCardElementManager().update).toBeCalled();
-    });
-  });
-
-  describe('should initialize background', () => {
-    it('without hass and config', async () => {
-      const manager = new InitializationManager(createCardAPI());
-      expect(await manager.initializeBackgroundIfNecessary()).toBeFalsy();
-    });
-
-    it('successfully when already initialized', async () => {
-      const api = createCardAPI();
-
-      const initializer = mock<Initializer>();
-      initializer.isInitializedMultiple.mockReturnValue(true);
-
-      const manager = new InitializationManager(api, initializer);
-      vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
-      vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
-        createConfig({
-          menu: {
-            buttons: {
-              media_player: {
-                enabled: false,
-              },
-            },
-          },
-        }),
-      );
-
-      expect(await manager.initializeBackgroundIfNecessary()).toBeTruthy();
-      expect(api.getMediaPlayerManager().initialize).not.toBeCalled();
-      expect(api.getDefaultManager().initialize).not.toBeCalled();
-      expect(api.getCardElementManager().update).not.toBeCalled();
-    });
-
-    it('successfully with all inititalizers', async () => {
-      const api = createCardAPI();
-      const manager = new InitializationManager(api);
-      vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
-      vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
-        createConfig({
-          menu: {
-            buttons: {
-              media_player: {
-                enabled: true,
-              },
-            },
-          },
-        }),
-      );
-
-      expect(await manager.initializeBackgroundIfNecessary()).toBeTruthy();
-      expect(api.getMediaPlayerManager().initialize).toBeCalled();
-      expect(api.getDefaultManager().initialize).toBeCalled();
-      expect(api.getCardElementManager().update).toBeCalled();
-    });
-
-    it('with initializers in progress', async () => {
-      const api = createCardAPI();
-      vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
-      vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
-        createConfig({
-          menu: {
-            buttons: {
-              media_player: {
-                enabled: true,
-              },
-            },
-          },
-        }),
-      );
-      const initializer = mock<Initializer>();
-
-      const manager = new InitializationManager(api, initializer);
-      initializer.initializeMultipleIfNecessary.mockResolvedValue(false);
-
-      expect(await manager.initializeBackgroundIfNecessary()).toBeFalsy();
+      await manager.initializeMandatory();
     });
   });
 
diff --git a/tests/card-controller/media-player-manager.test.ts b/tests/card-controller/media-player-manager.test.ts
index 9e4cb610..85776868 100644
--- a/tests/card-controller/media-player-manager.test.ts
+++ b/tests/card-controller/media-player-manager.test.ts
@@ -8,6 +8,7 @@ import {
   createCameraConfig,
   createCameraManager,
   createCardAPI,
+  createConfig,
   createHASS,
   createRegistryEntity,
   createStateEntity,
@@ -75,6 +76,17 @@ describe('MediaPlayerManager', () => {
       vi.mocked(api.getHASSManager().getHASS).mockReturnValue(
         createHASSWithMediaPlayers(),
       );
+      vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
+        createConfig({
+          menu: {
+            buttons: {
+              media_player: {
+                enabled: true,
+              },
+            },
+          },
+        }),
+      );
       vi.mocked(api.getEntityRegistryManager).mockReturnValue(entityRegistryManager);
       const manager = new MediaPlayerManager(api);
 
@@ -108,6 +120,17 @@ describe('MediaPlayerManager', () => {
       vi.mocked(api.getHASSManager().getHASS).mockReturnValue(
         createHASSWithMediaPlayers(),
       );
+      vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
+        createConfig({
+          menu: {
+            buttons: {
+              media_player: {
+                enabled: true,
+              },
+            },
+          },
+        }),
+      );
       vi.mocked(api.getEntityRegistryManager).mockReturnValue(entityRegistryManager);
       const manager = new MediaPlayerManager(api);
 
@@ -121,6 +144,39 @@ describe('MediaPlayerManager', () => {
       expect(manager.hasMediaPlayers()).toBeTruthy();
       expect(spy).toBeCalled();
     });
+
+    it('should reinitialize when there is a config change', async () => {
+      const entityRegistryManager = mock<EntityRegistryManager>();
+      entityRegistryManager.getEntities.mockResolvedValue(
+        new Map([
+          ['media_player.ok1', createRegistryEntity({ hidden_by: '' })],
+          ['media_player.ok2', createRegistryEntity({ hidden_by: 'user' })],
+        ]),
+      );
+
+      const api = createCardAPI();
+      vi.mocked(api.getHASSManager().getHASS).mockReturnValue(
+        createHASSWithMediaPlayers(),
+      );
+      const config = createConfig({
+        menu: {
+          buttons: {
+            media_player: {
+              enabled: true,
+            },
+          },
+        },
+      });
+      vi.mocked(api.getConfigManager().getConfig).mockReturnValue(config);
+      vi.mocked(api.getEntityRegistryManager).mockReturnValue(entityRegistryManager);
+      const manager = new MediaPlayerManager(api);
+
+      await manager.initializeIfNecessary(null);
+      expect(manager.hasMediaPlayers()).toBeTruthy();
+
+      await manager.initializeIfNecessary(config);
+      expect(manager.hasMediaPlayers()).toBeTruthy();
+    });
   });
 
   it('should stop', async () => {
diff --git a/tests/card-controller/query-string-manager.test.ts b/tests/card-controller/query-string-manager.test.ts
index 6ee8ae42..5ebf67dd 100644
--- a/tests/card-controller/query-string-manager.test.ts
+++ b/tests/card-controller/query-string-manager.test.ts
@@ -16,13 +16,13 @@ describe('QueryStringManager', () => {
     global.window.location = mock<Location>();
   });
 
-  it('should reject malformed query string', () => {
+  it('should reject malformed query string', async () => {
     setQueryString('BOGUS_KEY=BOGUS_VALUE');
     const api = createCardAPI();
     vi.mocked(api.getMessageManager().hasMessage).mockReturnValue(true);
     const manager = new QueryStringManager(api);
 
-    manager.executeAll();
+    await manager.executeAll();
 
     expect(manager.hasViewRelatedActions()).toBeFalsy();
     expect(api.getActionsManager().executeActions).not.toBeCalled();
@@ -41,7 +41,7 @@ describe('QueryStringManager', () => {
       ['snapshot' as const],
       ['snapshots' as const],
       ['timeline' as const],
-    ])('%s', (viewName: string) => {
+    ])('%s', async (viewName: string) => {
       setQueryString(`?frigate-card-action.id.${viewName}=`);
       const api = createCardAPI();
 
@@ -49,7 +49,7 @@ describe('QueryStringManager', () => {
       vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(false);
       const manager = new QueryStringManager(api);
 
-      manager.executeAll();
+      await manager.executeAll();
 
       expect(manager.hasViewRelatedActions()).toBeTruthy();
       expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
@@ -66,13 +66,13 @@ describe('QueryStringManager', () => {
       ['download' as const],
       ['expand' as const],
       ['menu_toggle' as const],
-    ])('%s', (action: string) => {
+    ])('%s', async (action: string) => {
       setQueryString(`?frigate-card-action.id.${action}=`);
       const api = createCardAPI();
       vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
       const manager = new QueryStringManager(api);
 
-      manager.executeAll();
+      await manager.executeAll();
 
       expect(manager.hasViewRelatedActions()).toBeFalsy();
       expect(api.getActionsManager().executeActions).toBeCalledWith([
@@ -85,7 +85,7 @@ describe('QueryStringManager', () => {
     });
   });
 
-  it('should execute view default action', () => {
+  it('should execute view default action', async () => {
     setQueryString('?frigate-card-action.id.default=');
     const api = createCardAPI();
     // View actions do not need the card to have been updated.
@@ -93,7 +93,7 @@ describe('QueryStringManager', () => {
 
     const manager = new QueryStringManager(api);
 
-    manager.executeAll();
+    await manager.executeAll();
 
     expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
 
@@ -102,13 +102,13 @@ describe('QueryStringManager', () => {
     expect(api.getViewManager().setViewByParameters).not.toBeCalled();
   });
 
-  it('should execute camera_select action', () => {
+  it('should execute camera_select action', async () => {
     setQueryString('?frigate-card-action.id.camera_select=camera.office');
     const api = createCardAPI();
     vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
     const manager = new QueryStringManager(api);
 
-    manager.executeAll();
+    await manager.executeAll();
 
     expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
       params: {
@@ -121,13 +121,13 @@ describe('QueryStringManager', () => {
     expect(api.getViewManager().setViewDefault).not.toBeCalled();
   });
 
-  it('should execute live_substream_select action', () => {
+  it('should execute live_substream_select action', async () => {
     setQueryString('?frigate-card-action.id.live_substream_select=camera.office_hd');
     const api = createCardAPI();
     vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
     const manager = new QueryStringManager(api);
 
-    manager.executeAll();
+    await manager.executeAll();
 
     expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
       modifiers: [expect.any(SubstreamSelectViewModifier)],
@@ -142,13 +142,13 @@ describe('QueryStringManager', () => {
   describe('should ignore action without value', () => {
     it.each([['camera_select' as const], ['live_substream_select' as const]])(
       '%s',
-      (action: string) => {
+      async (action: string) => {
         setQueryString(`?frigate-card-action.id.${action}=`);
         const api = createCardAPI();
         vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
         const manager = new QueryStringManager(api);
 
-        manager.executeAll();
+        await manager.executeAll();
 
         expect(manager.hasViewRelatedActions()).toBeFalsy();
         expect(api.getActionsManager().executeActions).not.toBeCalled();
@@ -158,7 +158,7 @@ describe('QueryStringManager', () => {
     );
   });
 
-  it('should handle unknown action', () => {
+  it('should handle unknown action', async () => {
     const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
 
     setQueryString('?frigate-card-action.id.not_an_action=value');
@@ -166,7 +166,7 @@ describe('QueryStringManager', () => {
     vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
     const manager = new QueryStringManager(api);
 
-    manager.executeAll();
+    await manager.executeAll();
 
     expect(manager.hasViewRelatedActions()).toBeFalsy();
     expect(api.getActionsManager().executeActions).not.toBeCalled();
@@ -187,13 +187,13 @@ describe('QueryStringManager', () => {
       ['snapshot' as const],
       ['snapshots' as const],
       ['timeline' as const],
-    ])('%s', (viewName: string) => {
+    ])('%s', async (viewName: string) => {
       setQueryString(`?frigate-card-action.id.${viewName}=`);
       const api = createCardAPI();
       vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
       const manager = new QueryStringManager(api);
 
-      manager.executeAll();
+      await manager.executeAll();
 
       expect(manager.hasViewRelatedActions()).toBeTruthy();
       expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
@@ -210,13 +210,13 @@ describe('QueryStringManager', () => {
       ['download' as const],
       ['expand' as const],
       ['menu_toggle' as const],
-    ])('%s', (action: string) => {
+    ])('%s', async (action: string) => {
       setQueryString(`?frigate-card-action.id.${action}=value`);
       const api = createCardAPI();
       vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(false);
       const manager = new QueryStringManager(api);
 
-      manager.executeAll();
+      await manager.executeAll();
 
       expect(api.getActionsManager().executeActions).not.toBeCalled();
       expect(api.getViewManager().setViewDefault).not.toBeCalled();
@@ -225,7 +225,7 @@ describe('QueryStringManager', () => {
   });
 
   describe('should handle conflicting but valid actions', () => {
-    it('view and default with camera and substream specified', () => {
+    it('view and default with camera and substream specified', async () => {
       setQueryString(
         '?frigate-card-action.id.clips=' +
           '&frigate-card-action.id.live_substream_select=camera.kitchen_hd' +
@@ -236,7 +236,7 @@ describe('QueryStringManager', () => {
       vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
       const manager = new QueryStringManager(api);
 
-      manager.executeAll();
+      await manager.executeAll();
 
       expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledWith({
         params: {
@@ -247,7 +247,7 @@ describe('QueryStringManager', () => {
       expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
     });
 
-    it('multiple cameras specified', () => {
+    it('multiple cameras specified', async () => {
       setQueryString(
         '?frigate-card-action.id.camera_select=camera.kitchen' +
           '&frigate-card-action.id.camera_select=camera.office',
@@ -256,7 +256,7 @@ describe('QueryStringManager', () => {
       vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
       const manager = new QueryStringManager(api);
 
-      manager.executeAll();
+      await manager.executeAll();
 
       expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
         params: {
@@ -279,13 +279,13 @@ describe('QueryStringManager', () => {
       ['snapshot' as const],
       ['snapshots' as const],
       ['timeline' as const],
-    ])('%s', (viewName: string) => {
+    ])('%s', async (viewName: string) => {
       setQueryString(`?frigate-card-action.id.${viewName}=`);
       const api = createCardAPI();
       vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
       const manager = new QueryStringManager(api);
 
-      manager.executeNonViewRelated();
+      await manager.executeNonViewRelated();
 
       expect(api.getViewManager().setViewByParameters).not.toBeCalled();
       expect(api.getViewManager().setViewDefault).not.toBeCalled();
@@ -298,13 +298,13 @@ describe('QueryStringManager', () => {
       ['download' as const],
       ['expand' as const],
       ['menu_toggle' as const],
-    ])('%s', (viewName: string) => {
+    ])('%s', async (viewName: string) => {
       setQueryString(`?frigate-card-action.id.${viewName}=`);
       const api = createCardAPI();
       vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
       const manager = new QueryStringManager(api);
 
-      manager.executeViewRelated();
+      await manager.executeViewRelated();
 
       expect(api.getActionsManager().executeActions).not.toBeCalled();
     });
diff --git a/tests/card-controller/view/view-manager.test.ts b/tests/card-controller/view/view-manager.test.ts
index 0f4094a6..de65c1d4 100644
--- a/tests/card-controller/view/view-manager.test.ts
+++ b/tests/card-controller/view/view-manager.test.ts
@@ -4,6 +4,8 @@ import { mock } from 'vitest-mock-extended';
 import { ViewFactory } from '../../../src/card-controller/view/factory';
 import { ViewManager } from '../../../src/card-controller/view/view-manager';
 import { FrigateCardView } from '../../../src/config/types';
+import { ViewMedia } from '../../../src/view/media';
+import { MediaQueriesResults } from '../../../src/view/media-queries-results';
 import {
   createCameraManager,
   createCapabilities,
@@ -11,8 +13,6 @@ import {
   createStore,
   createView,
 } from '../../test-utils';
-import { ViewMedia } from '../../../src/view/media';
-import { MediaQueriesResults } from '../../../src/view/media-queries-results';
 
 describe('should act correctly when view is set', () => {
   it('basic view', () => {
@@ -360,4 +360,33 @@ describe('hasMajorMediaChange', () => {
       manager.hasMajorMediaChange(createView({ queryResults: queryResults_2 })),
     ).toBeFalsy();
   });
+
+  describe('should initialize', () => {
+    it('without querystring', async () => {
+      const api = createCardAPI();
+      const factory = mock<ViewFactory>();
+      const manager = new ViewManager(api, factory);
+
+      const view = createView({
+        view: 'live',
+        camera: 'camera',
+      });
+      factory.getViewDefaultWithNewQuery.mockResolvedValue(view);
+
+      expect(await manager.initialize()).toBeTruthy();
+
+      expect(manager.getView()).toBe(view);
+    });
+
+    it('with querystring', async () => {
+      const api = createCardAPI();
+      const factory = mock<ViewFactory>();
+      const manager = new ViewManager(api, factory);
+      vi.mocked(api.getQueryStringManager().hasViewRelatedActions).mockReturnValue(true);
+
+      expect(await manager.initialize()).toBeTruthy();
+
+      expect(api.getQueryStringManager().executeViewRelated).toBeCalled();
+    });
+  });
 });
diff --git a/tests/utils/initializer.test.ts b/tests/utils/initializer.test.ts
new file mode 100644
index 00000000..7e0a34f0
--- /dev/null
+++ b/tests/utils/initializer.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from 'vitest';
+import { Initializer } from '../../src/utils/initializer/initializer';
+
+describe('Initializer', () => {
+  it('should initialize with initializer', async () => {
+    const initializer = new Initializer();
+
+    expect(initializer.isInitialized('foo')).toBeFalsy();
+    expect(
+      await initializer.initializeIfNecessary('foo', async () => true),
+    ).toBeTruthy();
+    expect(initializer.isInitialized('foo')).toBeTruthy();
+  });
+
+  it('should initialize without initializer', async () => {
+    const initializer = new Initializer();
+
+    expect(initializer.isInitialized('foo')).toBeFalsy();
+    expect(
+      await initializer.initializeIfNecessary('foo', async () => true),
+    ).toBeTruthy();
+    expect(initializer.isInitialized('foo')).toBeTruthy();
+  });
+
+  it('should initialize when already initialized', async () => {
+    const initializer = new Initializer();
+
+    expect(initializer.isInitialized('foo')).toBeFalsy();
+    expect(
+      await initializer.initializeIfNecessary('foo', async () => true),
+    ).toBeTruthy();
+    expect(
+      await initializer.initializeIfNecessary('foo', async () => true),
+    ).toBeTruthy();
+    expect(initializer.isInitialized('foo')).toBeTruthy();
+  });
+
+  it('should not initialize with failed initializer', async () => {
+    const initializer = new Initializer();
+
+    expect(initializer.isInitialized('foo')).toBeFalsy();
+    expect(
+      await initializer.initializeIfNecessary('foo', async () => false),
+    ).toBeFalsy();
+    expect(initializer.isInitialized('foo')).toBeFalsy();
+  });
+
+  it('should initialize multiple', async () => {
+    const initializer = new Initializer();
+
+    expect(initializer.isInitializedMultiple(['foo', 'bar'])).toBeFalsy();
+    expect(
+      await initializer.initializeMultipleIfNecessary({
+        foo: async () => true,
+        bar: async () => false,
+      }),
+    ).toBeFalsy();
+
+    expect(initializer.isInitializedMultiple(['foo', 'bar'])).toBeFalsy();
+
+    expect(
+      await initializer.initializeMultipleIfNecessary({
+        bar: async () => true,
+      }),
+    ).toBeTruthy();
+
+    expect(initializer.isInitializedMultiple(['foo', 'bar'])).toBeTruthy();
+  });
+
+  it('should uninitialize', async () => {
+    const initializer = new Initializer();
+    await initializer.initializeIfNecessary('foo');
+    expect(initializer.isInitialized('foo')).toBeTruthy();
+
+    initializer.uninitialize('foo');
+
+    expect(initializer.isInitialized('foo')).toBeFalsy();
+  });
+});