From a5ffd51728543e664bf414d5c4ea43b9d3e38d61 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 12 Apr 2025 10:48:37 +0100 Subject: [PATCH] fix: Lazy unloading should not leave dangling connections (#2004) - Closes #1992 --- src/components-lib/lazy-load-controller.ts | 102 ++++++++ src/components/carousel.ts | 13 +- src/components/live/carousel.ts | 34 +-- src/components/live/provider.ts | 37 ++- src/components/viewer/carousel.ts | 30 +-- src/components/viewer/provider.ts | 35 ++- .../plugins/auto-lazy-load/auto-lazy-load.ts | 137 ----------- .../lazy-load-controller.test.ts | 203 ++++++++++++++++ .../media-actions-controller.test.ts | 17 +- tests/test-utils.ts | 28 ++- .../auto-lazy-load/auto-lazy-load.test.ts | 220 ------------------ tests/utils/embla/test-utils.ts | 9 - 12 files changed, 397 insertions(+), 468 deletions(-) create mode 100644 src/components-lib/lazy-load-controller.ts delete mode 100644 src/utils/embla/plugins/auto-lazy-load/auto-lazy-load.ts create mode 100644 tests/components-lib/lazy-load-controller.test.ts delete mode 100644 tests/utils/embla/plugins/auto-lazy-load/auto-lazy-load.test.ts 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;