fix: Lazy unloading should not leave dangling connections (#2004)

- Closes #1992
This commit is contained in:
Dermot Duffy
2025-04-12 10:48:37 +01:00
committed by GitHub
parent a24c9628c5
commit a5ffd51728
12 changed files with 397 additions and 468 deletions
+102
View File
@@ -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();
};
}
+12 -1
View File
@@ -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,
+1 -33
View File
@@ -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`
<div class="embla__slide">
<advanced-camera-card-live-provider
?load=${!this.liveConfig.lazy_load}
.microphoneState=${view?.camera === cameraID
? this.microphoneState
: undefined}
+25 -12
View File
@@ -12,6 +12,7 @@ import { guard } from 'lit/directives/guard.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraEndpoints } from '../../camera-manager/types.js';
import { MicrophoneState } from '../../card-controller/types.js';
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js';
import { PartialZoomSettings } from '../../components-lib/zoom/types.js';
import { CameraConfig, LiveProvider } from '../../config/schema/cameras.js';
@@ -45,12 +46,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
@property({ attribute: false })
public liveConfig?: LiveConfig;
// Whether or not to load the video for this camera. If `false`, no contents
// are rendered until this attribute is set to `true` (this is useful for lazy
// loading).
@property({ attribute: true, type: Boolean })
public load = false;
// Label that is used for ARIA support and as tooltip.
@property({ attribute: false })
public label = '';
@@ -74,6 +69,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
protected _showStreamTroubleshooting = false;
protected _refProvider: Ref<MediaPlayerElement> = 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;
}
+1 -29
View File
@@ -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}
></advanced-camera-card-viewer-provider>
</div>`;
+23 -12
View File
@@ -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<MediaPlayerElement> = 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;
}
@@ -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<OptionsType>;
type AutoLazyLoadType = CreatePluginType<LoosePluginType, AutoLazyLoadOptionsType>;
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<number> = 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<number>();
// 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;
}