fix: prevent memory leaks from unmanaged subscriptions and event listeners

- LocalBackupService: add takeUntilDestroyed to backup interval subscription
- GlobalProgressBarService: add takeUntilDestroyed to dirty countdown subscription
- PluginBridgeService: extract window/document listeners to named methods with cleanup
- shepherd-helper: add take(1) to waitForElObs$, add 10s timeout to waitForEl
- plugin.service: add max attempts (100) to plugin loading poll interval
This commit is contained in:
Johannes Millan 2026-01-14 12:08:48 +01:00
parent a93f6a7ba2
commit c5625317bf
5 changed files with 59 additions and 23 deletions

View file

@ -1,5 +1,5 @@
import { Injectable, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { DestroyRef, inject, Injectable, signal } from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { EMPTY, Observable, of, timer } from 'rxjs';
import {
delay,
@ -26,6 +26,8 @@ interface CountUpOptions {
@Injectable({ providedIn: 'root' })
export class GlobalProgressBarService {
private _destroyRef = inject(DestroyRef);
// Use signals internally
private _nrOfRequests = signal(0);
private _label = signal<GlobalProgressBarLabel | null>(null);
@ -66,7 +68,7 @@ export class GlobalProgressBarService {
);
constructor() {
this._dirtyCountdown$.subscribe();
this._dirtyCountdown$.pipe(takeUntilDestroyed(this._destroyRef)).subscribe();
}
countUp(url: string, options?: CountUpOptions): void {

View file

@ -1,18 +1,24 @@
import { Observable, Subject, timer } from 'rxjs';
import { filter, first, map, takeUntil, tap } from 'rxjs/operators';
import { filter, first, map, take, takeUntil, tap } from 'rxjs/operators';
import { ShepherdService } from './shepherd.service';
import Step from 'shepherd.js/src/types/step';
import StepOptionsWhen = Step.StepOptionsWhen;
import { TourId } from './shepherd-steps.const';
import { Log } from '../../core/log';
const MAX_WAIT_TIME = 10000; // 10 seconds max wait
export const waitForEl = (selector: string, cb: () => void): number => {
const startTime = Date.now();
const int = window.setInterval(() => {
Log.log('INT');
if (document.querySelector(selector)) {
window.clearInterval(int);
cb();
} else if (Date.now() - startTime > MAX_WAIT_TIME) {
window.clearInterval(int);
Log.warn(`waitForEl: Timeout waiting for selector "${selector}"`);
}
}, 50);
return int;
@ -22,6 +28,7 @@ export const waitForElObs$ = (selector: string): Observable<any> => {
return timer(50, 50).pipe(
map(() => document.querySelector(selector)),
filter((el) => !!el),
take(1),
);
};

View file

@ -1,4 +1,5 @@
import { inject, Injectable } from '@angular/core';
import { DestroyRef, inject, Injectable } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { GlobalConfigService } from '../../features/config/global-config.service';
import { interval, Observable } from 'rxjs';
import { LocalBackupConfig } from '../../features/config/global-config.model';
@ -24,6 +25,7 @@ const ANDROID_DB_KEY = 'backup';
providedIn: 'root',
})
export class LocalBackupService {
private _destroyRef = inject(DestroyRef);
private _configService = inject(GlobalConfigService);
private _stateSnapshotService = inject(StateSnapshotService);
private _backupService = inject(BackupService);
@ -40,7 +42,7 @@ export class LocalBackupService {
);
init(): void {
this._triggerBackupSave$.subscribe();
this._triggerBackupSave$.pipe(takeUntilDestroyed(this._destroyRef)).subscribe();
}
checkBackupAvailable(): Promise<boolean | LocalBackupMeta> {

View file

@ -1156,27 +1156,41 @@ export class PluginBridgeService implements OnDestroy {
private _isWindowFocused = true;
private _windowFocusHandlers = new Map<string, (isFocused: boolean) => void>();
// Named listener methods for proper cleanup
private _onWindowFocus = (): void => {
this._isWindowFocused = true;
this._notifyFocusHandlers(true);
};
private _onWindowBlur = (): void => {
this._isWindowFocused = false;
this._notifyFocusHandlers(false);
};
private _onVisibilityChange = (): void => {
const isFocused = !document.hidden;
this._isWindowFocused = isFocused;
this._notifyFocusHandlers(isFocused);
};
/**
* Initialize window focus tracking
*/
private _initWindowFocusTracking(): void {
// Track window focus/blur events
window.addEventListener('focus', () => {
this._isWindowFocused = true;
this._notifyFocusHandlers(true);
});
window.addEventListener('blur', () => {
this._isWindowFocused = false;
this._notifyFocusHandlers(false);
});
window.addEventListener('focus', this._onWindowFocus);
window.addEventListener('blur', this._onWindowBlur);
// Also track document visibility changes
document.addEventListener('visibilitychange', () => {
const isFocused = !document.hidden;
this._isWindowFocused = isFocused;
this._notifyFocusHandlers(isFocused);
});
document.addEventListener('visibilitychange', this._onVisibilityChange);
}
/**
* Clean up window focus tracking listeners
*/
private _cleanupWindowFocusTracking(): void {
window.removeEventListener('focus', this._onWindowFocus);
window.removeEventListener('blur', this._onWindowBlur);
document.removeEventListener('visibilitychange', this._onVisibilityChange);
}
/**
@ -1412,6 +1426,7 @@ export class PluginBridgeService implements OnDestroy {
*/
ngOnDestroy(): void {
PluginLog.log('PluginBridgeService: Cleaning up resources');
this._cleanupWindowFocusTracking();
// Note: Signals don't need explicit cleanup like BehaviorSubjects
PluginLog.log('PluginBridgeService: Cleanup complete');
}

View file

@ -285,15 +285,25 @@ export class PluginService implements OnDestroy {
// If currently loading, wait for it
if (state.status === 'loading') {
// Wait for status to change
await new Promise<void>((resolve) => {
// Wait for status to change (max 10 seconds)
const maxAttempts = 100;
let attempts = 0;
await new Promise<void>((resolve, reject) => {
const checkStatus = setInterval(() => {
attempts++;
if (attempts > maxAttempts) {
clearInterval(checkStatus);
reject(new Error(`Plugin loading timeout after ${maxAttempts * 100}ms`));
return;
}
const currentState = this._getPluginState(pluginId);
if (currentState && currentState.status !== 'loading') {
clearInterval(checkStatus);
resolve();
}
}, 100);
}).catch((err) => {
console.error('Plugin activation error:', err);
});
const updatedState = this._getPluginState(pluginId);