diff --git a/src/components-lib/lazy-load-controller.ts b/src/components-lib/lazy-load-controller.ts
new file mode 100644
index 00000000..2440b295
--- /dev/null
+++ b/src/components-lib/lazy-load-controller.ts
@@ -0,0 +1,102 @@
+import { ReactiveController, ReactiveControllerHost } from 'lit';
+import { LazyUnloadCondition } from '../config/schema/common/media-actions';
+
+type LazyLoadListener = (loaded: boolean) => void;
+
+export class LazyLoadController implements ReactiveController {
+ private _host: ReactiveControllerHost & HTMLElement;
+ private _documentVisible = true;
+ private _intersects = false;
+ private _loaded: boolean;
+ private _unloadConditions: LazyUnloadCondition[] | null = null;
+ private _intersectionObserver = new IntersectionObserver(
+ this._intersectionHandler.bind(this),
+ );
+ private _listeners: LazyLoadListener[] = [];
+
+ constructor(
+ host: ReactiveControllerHost & HTMLElement,
+ lazyLoad?: boolean,
+ lazyUnloadConditions?: LazyUnloadCondition[],
+ ) {
+ this._host = host;
+ this._host.addController(this);
+
+ this._loaded = !lazyLoad;
+ this._unloadConditions = lazyUnloadConditions ?? null;
+ }
+
+ public destroy(): void {
+ this._removeEventHandlers();
+ this._listeners = [];
+ }
+
+ public isLoaded(): boolean {
+ return this._loaded;
+ }
+
+ public addListener(listener: LazyLoadListener): void {
+ this._listeners.push(listener);
+ }
+
+ public removeListener(listener: LazyLoadListener): void {
+ this._listeners = this._listeners.filter((l) => l !== listener);
+ }
+
+ public removeController(): void {
+ this._host.removeController(this);
+ }
+
+ public hostConnected(): void {
+ this._addEventHandlers();
+ }
+
+ public hostDisconnected(): void {
+ this._removeEventHandlers();
+ this._setLoaded(false);
+ }
+
+ private _addEventHandlers(): void {
+ document.addEventListener('visibilitychange', this._visibilityHandler);
+ this._intersectionObserver.observe(this._host);
+ }
+
+ private _removeEventHandlers(): void {
+ document.removeEventListener('visibilitychange', this._visibilityHandler);
+ this._intersectionObserver.disconnect();
+ }
+
+ private _lazyLoadOrUnloadIfNecessary(): void {
+ const shouldBeLoaded = !this._loaded && this._documentVisible && this._intersects;
+ const shouldBeUnloaded =
+ this._loaded &&
+ ((this._unloadConditions?.includes('hidden') && !this._documentVisible) ||
+ (this._unloadConditions?.includes('unselected') && !this._intersects));
+
+ if (shouldBeLoaded) {
+ this._setLoaded(true);
+ } else if (shouldBeUnloaded) {
+ this._setLoaded(false);
+ }
+ }
+
+ private _setLoaded(loaded: boolean): void {
+ this._loaded = loaded;
+ this._notifyListeners();
+ this._host.requestUpdate();
+ }
+
+ private _notifyListeners(): void {
+ this._listeners.forEach((listener) => listener(this._loaded));
+ }
+
+ private _intersectionHandler(entries: IntersectionObserverEntry[]): void {
+ this._intersects = entries.some((entry) => entry.isIntersecting);
+ this._lazyLoadOrUnloadIfNecessary();
+ }
+
+ private _visibilityHandler = (): void => {
+ this._documentVisible = document.visibilityState === 'visible';
+ this._lazyLoadOrUnloadIfNecessary();
+ };
+}
diff --git a/src/components/carousel.ts b/src/components/carousel.ts
index 915ff635..bd31687a 100644
--- a/src/components/carousel.ts
+++ b/src/components/carousel.ts
@@ -97,7 +97,18 @@ export class AdvancedCameraCardCarousel extends LitElement {
}
protected updated(changedProps: PropertyValues): void {
- if (!this._carousel && this._refRoot.value && this._refParent.value) {
+ if (
+ !this._carousel &&
+ this._refRoot.value &&
+ this._refParent.value &&
+ // Never construct a carousel if the node is not connected. There can be a
+ // race condition between the Lit update lifecycle, and the
+ // disconnect/connect callbacks, causing a carousel to potentially be
+ // created after the node is disconnected. This could cause a dangling
+ // carousel and hold open connections that should have been closed.
+ // See: https://github.com/dermotduffy/advanced-camera-card/issues/1992
+ this.isConnected
+ ) {
this._carousel = new CarouselController(
this._refRoot.value,
this._refParent.value,
diff --git a/src/components/live/carousel.ts b/src/components/live/carousel.ts
index 185a933e..94478aa8 100644
--- a/src/components/live/carousel.ts
+++ b/src/components/live/carousel.ts
@@ -24,7 +24,6 @@ import { HomeAssistant } from '../../ha/types.js';
import liveCarouselStyle from '../../scss/live-carousel.scss';
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
-import { AutoLazyLoad } from '../../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js';
import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
import AutoSize from '../../utils/embla/plugins/auto-size/auto-size.js';
import { getStreamCameraID } from '../../utils/substream.js';
@@ -36,7 +35,6 @@ import '../next-prev-control.js';
import '../ptz.js';
import { AdvancedCameraCardPTZ } from '../ptz.js';
import './provider.js';
-import { AdvancedCameraCardLiveProvider } from './provider.js';
const ADVANCED_CAMERA_CARD_LIVE_PROVIDER = 'advanced-camera-card-live-provider';
@@ -140,19 +138,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
}
protected _getPlugins(): EmblaCarouselPlugins {
- return [
- AutoLazyLoad({
- ...(this.liveConfig?.lazy_load && {
- lazyLoadCallback: (index, slide) =>
- this._lazyloadOrUnloadSlide('load', index, slide),
- }),
- lazyUnloadConditions: this.liveConfig?.lazy_unload,
- lazyUnloadCallback: (index, slide) =>
- this._lazyloadOrUnloadSlide('unload', index, slide),
- }),
- AutoMediaLoadedInfo(),
- AutoSize(),
- ];
+ return [AutoMediaLoadedInfo(), AutoSize()];
}
/**
@@ -217,23 +203,6 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
}
}
- protected _lazyloadOrUnloadSlide(
- action: 'load' | 'unload',
- _index: number,
- slide: Element,
- ): void {
- if (slide instanceof HTMLSlotElement) {
- slide = slide.assignedElements({ flatten: true })[0];
- }
-
- const liveProvider = slide?.querySelector(
- ADVANCED_CAMERA_CARD_LIVE_PROVIDER,
- ) as AdvancedCameraCardLiveProvider | null;
- if (liveProvider) {
- liveProvider.load = action === 'load';
- }
- }
-
protected _renderLive(
cameraID: string,
cameraConfig: CameraConfig,
@@ -248,7 +217,6 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
return html`
= createRef();
+ protected _lazyLoadController: LazyLoadController | null = null;
// A note on dynamic imports:
//
@@ -144,11 +140,23 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
}
protected willUpdate(changedProps: PropertyValues): void {
- if (changedProps.has('load')) {
- if (!this.load) {
- this._isVideoMediaLoaded = false;
- dispatchMediaUnloadedEvent(this);
- }
+ if (
+ changedProps.has('liveConfig') ||
+ (!this._lazyLoadController && this.liveConfig)
+ ) {
+ this._lazyLoadController?.destroy();
+ this._lazyLoadController?.removeController();
+ this._lazyLoadController = new LazyLoadController(
+ this,
+ this.liveConfig?.lazy_load,
+ this.liveConfig?.lazy_unload,
+ );
+ this._lazyLoadController.addListener((loaded: boolean) => {
+ if (!loaded) {
+ this._isVideoMediaLoaded = false;
+ dispatchMediaUnloadedEvent(this);
+ }
+ });
}
if (changedProps.has('liveConfig')) {
@@ -216,7 +224,12 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
}
protected render(): TemplateResult | void {
- if (!this.load || !this.hass || !this.liveConfig || !this.cameraConfig) {
+ if (
+ !this._lazyLoadController?.isLoaded() ||
+ !this.hass ||
+ !this.liveConfig ||
+ !this.cameraConfig
+ ) {
return;
}
diff --git a/src/components/viewer/carousel.ts b/src/components/viewer/carousel.ts
index 60cbd011..b3cea5ef 100644
--- a/src/components/viewer/carousel.ts
+++ b/src/components/viewer/carousel.ts
@@ -24,7 +24,6 @@ import { MediaLoadedInfo, MediaPlayerController } from '../../types.js';
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
import { contentsChanged, setOrRemoveAttribute } from '../../utils/basic.js';
import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
-import { AutoLazyLoad } from '../../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js';
import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
import AutoSize from '../../utils/embla/plugins/auto-size/auto-size.js';
import { ResolvedMediaCache } from '../../utils/ha/resolved-media.js';
@@ -36,7 +35,6 @@ import { renderMessage } from '../message.js';
import '../next-prev-control.js';
import '../ptz.js';
import './provider.js';
-import { AdvancedCameraCardViewerProvider } from './provider.js';
interface MediaNeighbor {
index: number;
@@ -137,15 +135,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
* @returns A list of EmblaOptionsTypes.
*/
protected _getPlugins(): EmblaCarouselPlugins {
- return [
- AutoLazyLoad({
- ...(this.viewerConfig?.lazy_load && {
- lazyLoadCallback: (_index, slide) => this._lazyloadSlide(slide),
- }),
- }),
- AutoMediaLoadedInfo(),
- AutoSize(),
- ];
+ return [AutoMediaLoadedInfo(), AutoSize()];
}
/**
@@ -211,23 +201,6 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
});
}
- /**
- * Lazy load a slide.
- * @param slide The slide to lazy load.
- */
- protected _lazyloadSlide(slide: Element): void {
- if (slide instanceof HTMLSlotElement) {
- slide = slide.assignedElements({ flatten: true })[0];
- }
-
- const viewerProvider = slide?.querySelector(
- 'advanced-camera-card-viewer-provider',
- ) as AdvancedCameraCardViewerProvider | null;
- if (viewerProvider) {
- viewerProvider.load = true;
- }
- }
-
/**
* Get slides to include in the render.
* @returns The slides to include in the render.
@@ -450,7 +423,6 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
.viewerConfig=${this.viewerConfig}
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
- .load=${!this.viewerConfig.lazy_load}
.cardWideConfig=${this.cardWideConfig}
>
`;
diff --git a/src/components/viewer/provider.ts b/src/components/viewer/provider.ts
index d6056077..1365fcf3 100644
--- a/src/components/viewer/provider.ts
+++ b/src/components/viewer/provider.ts
@@ -11,6 +11,7 @@ import { guard } from 'lit/directives/guard.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
+import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
import { CardWideConfig } from '../../config/schema/types.js';
@@ -61,12 +62,6 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
@property({ attribute: false })
public resolvedMediaCache?: ResolvedMediaCache;
- // Whether or not to load the viewer media. If `false`, no contents are
- // rendered until this attribute is set to `true` (this is useful for lazy
- // loading).
- @property({ attribute: false })
- public load = false;
-
@property({ attribute: false })
public cameraManager?: CameraManager;
@@ -74,6 +69,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
public cardWideConfig?: CardWideConfig;
protected _refProvider: Ref = createRef();
+ protected _lazyLoadController: LazyLoadController | null = null;
@state()
protected _url: string | null = null;
@@ -127,7 +123,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
!this.media ||
!mediaContentID ||
!this.hass ||
- (this.viewerConfig?.lazy_load && !this.load)
+ !this._lazyLoadController?.isLoaded()
) {
return;
}
@@ -185,15 +181,25 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
protected willUpdate(changedProps: PropertyValues): void {
if (
- changedProps.has('load') ||
+ changedProps.has('viewerConfig') ||
+ (!this._lazyLoadController && this.viewerConfig)
+ ) {
+ this._lazyLoadController?.destroy();
+ this._lazyLoadController?.removeController();
+ this._lazyLoadController = new LazyLoadController(
+ this,
+ this.viewerConfig?.lazy_load,
+ );
+ this._lazyLoadController.addListener((loaded) => loaded && this._setURL());
+ }
+
+ if (
changedProps.has('media') ||
changedProps.has('viewerConfig') ||
changedProps.has('resolvedMediaCache') ||
changedProps.has('hass')
) {
- this._setURL().then(() => {
- this.requestUpdate();
- });
+ this._setURL();
}
if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) {
@@ -246,7 +252,12 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
}
protected render(): TemplateResult | void {
- if (!this.load || !this.media || !this.hass || !this.viewerConfig) {
+ if (
+ !this._lazyLoadController?.isLoaded() ||
+ !this.media ||
+ !this.hass ||
+ !this.viewerConfig
+ ) {
return;
}
diff --git a/src/utils/embla/plugins/auto-lazy-load/auto-lazy-load.ts b/src/utils/embla/plugins/auto-lazy-load/auto-lazy-load.ts
deleted file mode 100644
index da9092bc..00000000
--- a/src/utils/embla/plugins/auto-lazy-load/auto-lazy-load.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
-import { CreateOptionsType } from 'embla-carousel/components/Options';
-import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler';
-import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
-import { LazyUnloadCondition } from '../../../../config/schema/common/media-actions';
-
-declare module 'embla-carousel/components/Plugins' {
- interface EmblaPluginsType {
- lazyload?: AutoLazyLoadType;
- }
-}
-
-type OptionsType = CreateOptionsType<{
- // Number of slides to lazyload left/right of selected (0 == only selected
- // slide).
- lazyLoadCount: number;
- lazyUnloadConditions?: readonly LazyUnloadCondition[];
-
- lazyLoadCallback?: (index: number, slide: HTMLElement) => void;
- lazyUnloadCallback?: (index: number, slide: HTMLElement) => void;
-}>;
-type AutoLazyLoadOptionsType = Partial;
-type AutoLazyLoadType = CreatePluginType;
-
-const defaultOptions: OptionsType = {
- active: true,
- breakpoints: {},
- lazyLoadCount: 0,
-};
-
-export function AutoLazyLoad(
- userOptions: AutoLazyLoadOptionsType = {},
-): AutoLazyLoadType {
- let options: OptionsType;
- let emblaApi: EmblaCarouselType;
- let slides: HTMLElement[];
- const lazyLoadedSlides: Set = new Set();
-
- const loadEvents: EmblaEventType[] = ['init', 'select'];
- const unloadEvents: EmblaEventType[] = ['select'];
-
- function init(
- emblaApiInstance: EmblaCarouselType,
- optionsHandler: OptionsHandlerType,
- ): void {
- const { mergeOptions, optionsAtMedia } = optionsHandler;
- const allOptions = mergeOptions(defaultOptions, userOptions);
- options = optionsAtMedia(allOptions);
-
- emblaApi = emblaApiInstance;
- slides = emblaApi.slideNodes();
-
- if (options.lazyLoadCallback) {
- loadEvents.forEach((evt) => emblaApi.on(evt, lazyLoadHandler));
- }
- if (
- options.lazyUnloadCallback &&
- options.lazyUnloadConditions?.includes('unselected')
- ) {
- unloadEvents.forEach((evt) => emblaApi.on(evt, lazyUnloadPreviousHandler));
- }
- document.addEventListener('visibilitychange', visibilityHandler);
- }
-
- function destroy(): void {
- if (options.lazyLoadCallback) {
- loadEvents.forEach((evt) => emblaApi.off(evt, lazyLoadHandler));
- }
- if (options.lazyUnloadCallback) {
- unloadEvents.forEach((evt) => emblaApi.off(evt, lazyUnloadPreviousHandler));
- }
- document.removeEventListener('visibilitychange', visibilityHandler);
- }
-
- function visibilityHandler(): void {
- if (
- document.visibilityState === 'hidden' &&
- options.lazyUnloadConditions?.includes('hidden')
- ) {
- lazyUnloadAllHandler();
- } else if (document.visibilityState === 'visible' && options.lazyLoadCallback) {
- lazyLoadHandler();
- }
- }
-
- function hasLazyloaded(index: number): boolean {
- return lazyLoadedSlides.has(index);
- }
-
- function lazyLoadHandler(): void {
- const lazyLoadCount = options.lazyLoadCount;
- const currentIndex = emblaApi.selectedScrollSnap();
- const slidesToLoad = new Set();
-
- // Lazily load 'count' slides on either side of the slides in view.
- for (let i = 1; i <= lazyLoadCount && currentIndex - i >= 0; i++) {
- slidesToLoad.add(currentIndex - i);
- }
- slidesToLoad.add(currentIndex);
- for (let i = 1; i <= lazyLoadCount && currentIndex + i < slides.length; i++) {
- slidesToLoad.add(currentIndex + i);
- }
-
- slidesToLoad.forEach((index) => {
- if (!hasLazyloaded(index) && options.lazyLoadCallback) {
- lazyLoadedSlides.add(index);
- options.lazyLoadCallback(index, slides[index]);
- }
- });
- }
-
- function lazyUnloadAllHandler(): void {
- lazyLoadedSlides.forEach((index) => {
- if (options.lazyUnloadCallback) {
- options.lazyUnloadCallback(index, slides[index]);
- lazyLoadedSlides.delete(index);
- }
- });
- }
-
- function lazyUnloadPreviousHandler(): void {
- const index = emblaApi.previousScrollSnap();
-
- if (hasLazyloaded(index) && options.lazyUnloadCallback) {
- options.lazyUnloadCallback(index, slides[index]);
- lazyLoadedSlides.delete(index);
- }
- }
-
- const self: AutoLazyLoadType = {
- name: 'autoLazyLoad',
- options: userOptions,
- init,
- destroy,
- };
- return self;
-}
diff --git a/tests/components-lib/lazy-load-controller.test.ts b/tests/components-lib/lazy-load-controller.test.ts
new file mode 100644
index 00000000..90bd924a
--- /dev/null
+++ b/tests/components-lib/lazy-load-controller.test.ts
@@ -0,0 +1,203 @@
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from 'vitest';
+import { LazyLoadController } from '../../src/components-lib/lazy-load-controller';
+import { LazyUnloadCondition } from '../../src/config/schema/common/media-actions';
+import {
+ callIntersectionHandler,
+ callVisibilityHandler,
+ createLitElement,
+ getMockIntersectionObserver,
+ IntersectionObserverMock,
+} from '../test-utils';
+
+// @vitest-environment jsdom
+describe('LazyLoadController', () => {
+ beforeAll(() => {
+ vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
+ });
+
+ afterAll(() => {
+ vi.unstubAllGlobals();
+ });
+
+ beforeEach(() => {
+ vi.spyOn(global.document, 'addEventListener');
+ vi.spyOn(global.document, 'removeEventListener');
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should be loaded by default', () => {
+ const controller = new LazyLoadController(createLitElement());
+ expect(controller.isLoaded()).toBe(true);
+ });
+
+ it('should not be loaded by default when lazy load is set to true', () => {
+ const controller = new LazyLoadController(createLitElement(), true);
+ expect(controller.isLoaded()).toBe(false);
+ });
+
+ it('should add controller to host', () => {
+ const host = createLitElement();
+ const controller = new LazyLoadController(host);
+ expect(host.addController).toBeCalledWith(controller);
+ });
+
+ it('should remove controller from host', () => {
+ const host = createLitElement();
+ const controller = new LazyLoadController(host);
+ controller.removeController();
+ expect(host.removeController).toBeCalledWith(controller);
+ });
+
+ it('should remove handlers and listeners on destroy', () => {
+ const controller = new LazyLoadController(createLitElement(), true, [
+ 'unselected',
+ 'hidden',
+ ]);
+ controller.hostConnected();
+
+ const listener = vi.fn();
+ controller.addListener(listener);
+
+ controller.destroy();
+
+ expect(getMockIntersectionObserver()?.disconnect).toBeCalled();
+ expect(global.document.removeEventListener).toBeCalledWith(
+ 'visibilitychange',
+ expect.anything(),
+ );
+ expect(controller.isLoaded()).toBe(false);
+
+ callVisibilityHandler(true);
+ callIntersectionHandler(true);
+ expect(listener).not.toBeCalled();
+ });
+
+ describe('should lazy load', () => {
+ it('should load when both visible and intersecting', () => {
+ const controller = new LazyLoadController(createLitElement(), true);
+ controller.hostConnected();
+
+ expect(controller.isLoaded()).toBe(false);
+
+ callVisibilityHandler(true);
+ expect(controller.isLoaded()).toBe(false);
+
+ callIntersectionHandler(true);
+ expect(controller.isLoaded()).toBe(true);
+ });
+ });
+
+ describe('should lazy unload', () => {
+ it('should unload on DOM disconnection', () => {
+ const controller = new LazyLoadController(createLitElement());
+ controller.hostConnected();
+
+ expect(controller.isLoaded()).toBe(true);
+
+ controller.hostDisconnected();
+
+ expect(controller.isLoaded()).toBe(false);
+
+ // Should also stop observing.
+ expect(getMockIntersectionObserver()?.disconnect).toBeCalled();
+ expect(global.document.removeEventListener).toBeCalledWith(
+ 'visibilitychange',
+ expect.anything(),
+ );
+ });
+
+ describe('should lazy unload when not visible', () => {
+ it.each([
+ [[], true],
+ [['unselected' as const], true],
+ [['hidden' as const], false],
+ [['unselected' as const, 'hidden' as const], false],
+ ])(
+ 'when unload conditions are: %s',
+ (unloadConditions: LazyUnloadCondition[], shouldBeLoaded: boolean) => {
+ const controller = new LazyLoadController(
+ createLitElement(),
+ true,
+ unloadConditions,
+ );
+ controller.hostConnected();
+
+ callIntersectionHandler(true);
+ callVisibilityHandler(true);
+ expect(controller.isLoaded()).toBe(true);
+
+ callVisibilityHandler(false);
+ expect(controller.isLoaded()).toBe(shouldBeLoaded);
+ },
+ );
+ });
+
+ describe('should lazy unload when not intersecting', () => {
+ it.each([
+ [[], true],
+ [['unselected' as const], false],
+ [['hidden' as const], true],
+ [['unselected' as const, 'hidden' as const], false],
+ ])(
+ 'when unload conditions are: %s',
+ (unloadConditions: LazyUnloadCondition[], shouldBeLoaded: boolean) => {
+ const controller = new LazyLoadController(
+ createLitElement(),
+ true,
+ unloadConditions,
+ );
+ controller.hostConnected();
+
+ callIntersectionHandler(true);
+ callVisibilityHandler(true);
+ expect(controller.isLoaded()).toBe(true);
+
+ callIntersectionHandler(false);
+ expect(controller.isLoaded()).toBe(shouldBeLoaded);
+ },
+ );
+ });
+ });
+
+ it('should call listeners', () => {
+ const listener = vi.fn();
+ const controller = new LazyLoadController(createLitElement(), true, [
+ 'unselected',
+ 'hidden',
+ ]);
+ controller.hostConnected();
+ controller.addListener(listener);
+
+ expect(controller.isLoaded()).toBe(false);
+
+ callIntersectionHandler(true);
+ callVisibilityHandler(true);
+ expect(listener).toHaveBeenLastCalledWith(true);
+ expect(listener).toBeCalledTimes(1);
+
+ callIntersectionHandler(false);
+ expect(listener).toHaveBeenLastCalledWith(false);
+ expect(listener).toBeCalledTimes(2);
+
+ callIntersectionHandler(true);
+ expect(listener).toHaveBeenLastCalledWith(true);
+ expect(listener).toBeCalledTimes(3);
+
+ controller.removeListener(listener);
+
+ callIntersectionHandler(false);
+ expect(listener).toBeCalledTimes(3);
+ });
+});
diff --git a/tests/components-lib/media-actions-controller.test.ts b/tests/components-lib/media-actions-controller.test.ts
index c2bffe23..695e6dd0 100644
--- a/tests/components-lib/media-actions-controller.test.ts
+++ b/tests/components-lib/media-actions-controller.test.ts
@@ -11,10 +11,11 @@ import {
MutationObserverMock,
callIntersectionHandler,
callMutationHandler,
+ callVisibilityHandler,
createParent,
flushPromises,
} from '../test-utils';
-import { callVisibilityHandler, createTestSlideNodes } from '../utils/embla/test-utils';
+import { createTestSlideNodes } from '../utils/embla/test-utils';
const getPlayer = (
element: HTMLElement,
@@ -47,7 +48,7 @@ describe('MediaActionsController', () => {
});
afterAll(() => {
- vi.restoreAllMocks();
+ vi.unstubAllGlobals();
});
beforeEach(() => {
@@ -455,11 +456,7 @@ describe('MediaActionsController', () => {
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
).not.toBeCalled();
- Object.defineProperty(document, 'visibilityState', {
- value: 'visible',
- writable: true,
- });
- await callVisibilityHandler();
+ await callVisibilityHandler(true);
// Not configured to take action on selection.
expect(
@@ -502,11 +499,7 @@ describe('MediaActionsController', () => {
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
).not.toBeCalled();
- Object.defineProperty(document, 'visibilityState', {
- value: 'hidden',
- writable: true,
- });
- await callVisibilityHandler();
+ await callVisibilityHandler(false);
// Not configured to take action on selection.
expect(
diff --git a/tests/test-utils.ts b/tests/test-utils.ts
index 529f2cb8..a0f9b747 100644
--- a/tests/test-utils.ts
+++ b/tests/test-utils.ts
@@ -383,15 +383,22 @@ export const requestAnimationFrameMock = (callback: FrameRequestCallback) => {
return 1;
};
+export const getMockIntersectionObserver = (n = 0): IntersectionObserver | null => {
+ const mockResult = vi.mocked(IntersectionObserver).mock.results[n];
+ if (mockResult.type !== 'return') {
+ return null;
+ }
+ return mockResult.value;
+};
+
export const callIntersectionHandler = async (
intersecting = true,
n = 0,
): Promise => {
- const mockResult = vi.mocked(IntersectionObserver).mock.results[n];
- if (mockResult.type !== 'return') {
+ const observer = getMockIntersectionObserver(n);
+ if (!observer) {
return;
}
- const observer = mockResult.value;
await (
vi.mocked(IntersectionObserver).mock.calls[n][0] as
| IntersectionObserverCallback
@@ -422,6 +429,20 @@ export const callMutationHandler = async (n = 0): Promise => {
);
};
+export const callVisibilityHandler = async (visible: boolean): Promise => {
+ Object.defineProperty(document, 'visibilityState', {
+ value: visible ? 'visible' : 'hidden',
+ writable: true,
+ });
+
+ const mock = vi.mocked(global.document.addEventListener).mock;
+ for (const [evt, cb] of mock.calls) {
+ if (evt === 'visibilitychange' && typeof cb === 'function') {
+ await (cb as EventListener | ((_: unknown) => Promise))(new Event('foo'));
+ }
+ }
+};
+
export const createSlotHost = (options?: {
slot?: HTMLSlotElement;
children?: HTMLElement[];
@@ -453,6 +474,7 @@ export const createParent = (options?: { children?: HTMLElement[] }): HTMLElemen
export const createLitElement = (): LitElement => {
const element = document.createElement('div') as unknown as LitElement;
element.addController = vi.fn();
+ element.removeController = vi.fn();
element.requestUpdate = vi.fn();
const promise: Promise = new Promise((resolve) => {
diff --git a/tests/utils/embla/plugins/auto-lazy-load/auto-lazy-load.test.ts b/tests/utils/embla/plugins/auto-lazy-load/auto-lazy-load.test.ts
deleted file mode 100644
index 2a0225eb..00000000
--- a/tests/utils/embla/plugins/auto-lazy-load/auto-lazy-load.test.ts
+++ /dev/null
@@ -1,220 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-import { MEDIA_ACTION_NEGATIVE_CONDITIONS } from '../../../../../src/config/schema/common/media-actions';
-import { AutoLazyLoad } from '../../../../../src/utils/embla/plugins/auto-lazy-load/auto-lazy-load';
-import {
- callEmblaHandler,
- callVisibilityHandler,
- createEmblaApiInstance,
- createTestEmblaOptionHandler,
- createTestSlideNodes,
-} from '../../test-utils';
-
-// @vitest-environment jsdom
-describe('AutoLazyLoad', () => {
- it('should construct', () => {
- const plugin = AutoLazyLoad();
- expect(plugin.name).toBe('autoLazyLoad');
- });
-
- it('should destroy', () => {
- const plugin = AutoLazyLoad({
- lazyLoadCallback: vi.fn(),
- lazyUnloadCallback: vi.fn(),
- });
- const emblaApi = createEmblaApiInstance();
- plugin.init(emblaApi, createTestEmblaOptionHandler());
- plugin.destroy();
-
- expect(emblaApi.off).toBeCalledWith('init', expect.anything());
- expect(emblaApi.off).toBeCalledWith('select', expect.anything());
- });
-
- it('should do nothing without callbacks', () => {
- const plugin = AutoLazyLoad({
- // No callbacks provided.
- });
-
- const children = createTestSlideNodes();
- const emblaApi = createEmblaApiInstance({ slideNodes: children });
-
- plugin.init(emblaApi, createTestEmblaOptionHandler());
- expect(emblaApi.on).not.toBeCalled();
-
- plugin.destroy();
- expect(emblaApi.off).not.toBeCalled();
- });
-
- it('should lazy load single slide on select', () => {
- const lazyLoadCallback = vi.fn();
- const plugin = AutoLazyLoad({
- lazyLoadCallback: lazyLoadCallback,
- });
-
- const children = createTestSlideNodes();
- const emblaApi = createEmblaApiInstance({ slideNodes: children });
- plugin.init(emblaApi, createTestEmblaOptionHandler());
-
- expect(emblaApi.on).toBeCalledWith('init', expect.anything());
- expect(emblaApi.on).toBeCalledWith('select', expect.anything());
-
- callEmblaHandler(emblaApi, 'init');
- expect(lazyLoadCallback).toBeCalledWith(0, children[0]);
-
- callEmblaHandler(emblaApi, 'select');
-
- // The select call will not re-lazyload the same slide.
- expect(lazyLoadCallback).toBeCalledTimes(1);
- });
-
- it('should lazy load multiple slides on select', () => {
- const lazyLoadCallback = vi.fn();
- const plugin = AutoLazyLoad({
- lazyLoadCallback: lazyLoadCallback,
- lazyLoadCount: 3,
- });
-
- const children = createTestSlideNodes();
- const emblaApi = createEmblaApiInstance({
- slideNodes: children,
- selectedScrollSnap: 5,
- });
- plugin.init(emblaApi, createTestEmblaOptionHandler());
-
- callEmblaHandler(emblaApi, 'select');
- for (let i = 3; i <= 8; ++i) {
- expect(lazyLoadCallback).toBeCalledWith(i, children[i]);
- }
- });
-
- it('should lazy unload on select', () => {
- const lazyUnloadCallback = vi.fn();
- const plugin = AutoLazyLoad({
- lazyLoadCallback: vi.fn(),
- lazyLoadCount: 3,
- lazyUnloadCallback: lazyUnloadCallback,
- lazyUnloadConditions: MEDIA_ACTION_NEGATIVE_CONDITIONS,
- });
-
- const children = createTestSlideNodes();
- const emblaApi = createEmblaApiInstance({
- selectedScrollSnap: 5,
- slideNodes: children,
- });
- plugin.init(emblaApi, createTestEmblaOptionHandler());
-
- callEmblaHandler(emblaApi, 'select');
-
- // First call will not unload anything, since it was not lazy loaded.
- expect(lazyUnloadCallback).not.toBeCalled();
-
- vi.mocked(emblaApi.previousScrollSnap).mockReturnValue(5);
- callEmblaHandler(emblaApi, 'select');
-
- // Second call should lazy unload the previous slide.
- expect(lazyUnloadCallback).toBeCalledWith(5, children[5]);
- });
-
- it('should lazy load on visibility', () => {
- vi.spyOn(global.document, 'addEventListener');
-
- const lazyLoadCallback = vi.fn();
- const plugin = AutoLazyLoad({
- lazyLoadCallback: lazyLoadCallback,
- });
-
- const children = createTestSlideNodes();
- const emblaApi = createEmblaApiInstance({
- slideNodes: children,
- });
- plugin.init(emblaApi, createTestEmblaOptionHandler());
-
- Object.defineProperty(document, 'visibilityState', {
- value: 'visible',
- writable: true,
- });
- callVisibilityHandler();
- expect(lazyLoadCallback).toBeCalledWith(0, children[0]);
- });
-
- it('should lazy unload on visibility', () => {
- vi.spyOn(global.document, 'addEventListener');
-
- const lazyUnloadCallback = vi.fn();
-
- const plugin = AutoLazyLoad({
- lazyLoadCallback: vi.fn(),
- lazyUnloadCallback: lazyUnloadCallback,
- lazyUnloadConditions: MEDIA_ACTION_NEGATIVE_CONDITIONS,
- });
-
- const children = createTestSlideNodes();
- const emblaApi = createEmblaApiInstance({
- slideNodes: children,
- });
- plugin.init(emblaApi, createTestEmblaOptionHandler());
-
- Object.defineProperty(document, 'visibilityState', {
- value: 'visible',
- writable: true,
- });
- callVisibilityHandler();
-
- Object.defineProperty(document, 'visibilityState', {
- value: 'hidden',
- writable: true,
- });
- callVisibilityHandler();
- expect(lazyUnloadCallback).toBeCalledWith(0, children[0]);
- });
-
- it('should not lazy unload on visibility without a callback', () => {
- vi.spyOn(global.document, 'addEventListener');
-
- const lazyLoadCallback = vi.fn();
- const plugin = AutoLazyLoad({
- lazyLoadCallback: lazyLoadCallback,
- lazyUnloadConditions: MEDIA_ACTION_NEGATIVE_CONDITIONS,
- // No lazy unload callback.
- });
- const children = createTestSlideNodes();
- const emblaApi = createEmblaApiInstance({
- slideNodes: children,
- });
- plugin.init(emblaApi, createTestEmblaOptionHandler());
-
- Object.defineProperty(document, 'visibilityState', {
- value: 'visible',
- writable: true,
- });
- callVisibilityHandler();
- expect(lazyLoadCallback).toBeCalledTimes(1);
-
- Object.defineProperty(document, 'visibilityState', {
- value: 'hidden',
- writable: true,
- });
- callVisibilityHandler();
- expect(lazyLoadCallback).toBeCalledTimes(1);
- });
-
- it('should not lazy load or unload on visibility when no callback provided', () => {
- vi.spyOn(global.document, 'addEventListener');
-
- const plugin = AutoLazyLoad({
- lazyUnloadConditions: MEDIA_ACTION_NEGATIVE_CONDITIONS,
- // No callbacks provided.
- });
-
- const children = createTestSlideNodes();
- const emblaApi = createEmblaApiInstance({
- slideNodes: children,
- });
- plugin.init(emblaApi, createTestEmblaOptionHandler());
-
- Object.defineProperty(document, 'visibilityState', {
- value: 'visible',
- writable: true,
- });
- callVisibilityHandler();
- });
-});
diff --git a/tests/utils/embla/test-utils.ts b/tests/utils/embla/test-utils.ts
index 66779fe6..045ca4f2 100644
--- a/tests/utils/embla/test-utils.ts
+++ b/tests/utils/embla/test-utils.ts
@@ -35,15 +35,6 @@ export const callEmblaHandler = (
}
};
-export const callVisibilityHandler = async (): Promise => {
- const mock = vi.mocked(global.document.addEventListener).mock;
- for (const [evt, cb] of mock.calls) {
- if (evt === 'visibilitychange' && typeof cb === 'function') {
- await (cb as EventListener | ((_: unknown) => Promise))(new Event('foo'));
- }
- }
-};
-
export const callResizeHandler = (
entries: {
target: HTMLElement;