@fivexlabs/ng-terminus
Version:
A comprehensive Angular library for managing RxJS subscriptions and preventing memory leaks with advanced features
1,501 lines (1,490 loc) • 57.5 kB
JavaScript
import * as i0 from '@angular/core';
import { inject, DestroyRef, Injectable, NgModule, DOCUMENT } from '@angular/core';
import { Subject, takeUntil, isObservable, throwError, Observable, EMPTY, merge, fromEvent, BehaviorSubject, ReplaySubject } from 'rxjs';
import { tap, finalize, share, takeUntil as takeUntil$1, catchError, filter, switchMap, startWith, map, distinctUntilChanged, shareReplay } from 'rxjs/operators';
import { Router, NavigationEnd } from '@angular/router';
/**
* An RxJS operator that automatically completes the source observable
* when the associated Angular component, directive, or service is destroyed.
*
* This operator prevents memory leaks by tying the observable lifecycle
* to Angular's component lifecycle through DestroyRef.
*
* @param destroyRef Optional DestroyRef instance. If not provided,
* it will be automatically injected using Angular's inject() function.
* @returns An operator function that can be used in a pipe() chain
*
* @example
* ```typescript
* // Basic usage with automatic DestroyRef injection
* constructor(private dataService: DataService) {
* this.dataService.getData()
* .pipe(takeUntilDestroyed())
* .subscribe(data => console.log(data));
* }
*
* // Usage with explicit DestroyRef
* constructor(private dataService: DataService) {
* const destroyRef = inject(DestroyRef);
* this.dataService.getData()
* .pipe(takeUntilDestroyed(destroyRef))
* .subscribe(data => console.log(data));
* }
* ```
*/
function takeUntilDestroyed(destroyRef) {
return (source) => {
const destroy$ = new Subject();
// Use provided DestroyRef or inject it automatically
const ref = destroyRef ?? inject(DestroyRef);
// Register cleanup callback
ref.onDestroy(() => {
destroy$.next();
destroy$.complete();
});
return source.pipe(takeUntil(destroy$));
};
}
/**
* A simplified version of takeUntilDestroyed that always uses automatic injection.
* This provides the cleanest API for most use cases.
*
* @returns An operator function that can be used in a pipe() chain
*
* @example
* ```typescript
* constructor(private dataService: DataService) {
* this.dataService.getData()
* .pipe(untilDestroyed())
* .subscribe(data => console.log(data));
* }
* ```
*/
function untilDestroyed() {
return takeUntilDestroyed();
}
/**
* A service that manages multiple RxJS subscriptions and automatically
* unsubscribes from all of them when the associated component is destroyed.
*
* This service implements Angular's OnDestroy interface and should be
* provided at the component level to ensure proper cleanup.
*
* @example
* ```typescript
* @Component({
* selector: 'app-my-component',
* providers: [SubscriptionManager] // Provide at component level
* })
* export class MyComponent implements OnInit {
* constructor(
* private dataService: DataService,
* private subManager: SubscriptionManager
* ) {}
*
* ngOnInit() {
* // Add subscriptions to the manager
* this.subManager.add(
* this.dataService.getStream1().subscribe(data => console.log(data)),
* this.dataService.getStream2().subscribe(data => console.log(data))
* );
* }
* // No ngOnDestroy needed - the service handles cleanup automatically
* }
* ```
*/
class SubscriptionManager {
subscriptions = new Set();
isDestroyed = false;
/**
* Add one or more subscriptions to be managed by this service.
* All added subscriptions will be automatically unsubscribed when
* the component is destroyed.
*
* @param subscriptions The subscriptions to add
* @returns The SubscriptionManager instance for method chaining
*
* @example
* ```typescript
* // Add single subscription
* this.subManager.add(observable$.subscribe());
*
* // Add multiple subscriptions
* this.subManager.add(
* observable1$.subscribe(),
* observable2$.subscribe()
* );
*
* // Method chaining
* this.subManager
* .add(observable1$.subscribe())
* .add(observable2$.subscribe());
* ```
*/
add(...subscriptions) {
if (this.isDestroyed) {
console.warn('SubscriptionManager: Attempting to add subscriptions after destroy. Subscriptions will be immediately unsubscribed.');
subscriptions.forEach(sub => sub.unsubscribe());
return this;
}
subscriptions.forEach(subscription => {
if (subscription && !subscription.closed) {
this.subscriptions.add(subscription);
}
});
return this;
}
/**
* Remove a specific subscription from management.
* The subscription will not be unsubscribed automatically.
*
* @param subscription The subscription to remove
* @returns The SubscriptionManager instance for method chaining
*/
remove(subscription) {
this.subscriptions.delete(subscription);
return this;
}
/**
* Manually unsubscribe from all managed subscriptions.
* This is automatically called during ngOnDestroy.
*/
unsubscribeAll() {
this.subscriptions.forEach(subscription => {
if (subscription && !subscription.closed) {
subscription.unsubscribe();
}
});
this.subscriptions.clear();
}
/**
* Get the current number of active subscriptions being managed.
*
* @returns The number of active subscriptions
*/
get activeCount() {
// Filter out closed subscriptions
const activeSubscriptions = Array.from(this.subscriptions).filter(sub => !sub.closed);
// Clean up closed subscriptions from the set
if (activeSubscriptions.length !== this.subscriptions.size) {
this.subscriptions.clear();
activeSubscriptions.forEach(sub => this.subscriptions.add(sub));
}
return activeSubscriptions.length;
}
/**
* Check if the manager has any active subscriptions.
*
* @returns True if there are active subscriptions, false otherwise
*/
get hasActiveSubscriptions() {
return this.activeCount > 0;
}
/**
* Angular lifecycle hook that automatically unsubscribes from all
* managed subscriptions when the component is destroyed.
*/
ngOnDestroy() {
this.isDestroyed = true;
this.unsubscribeAll();
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: SubscriptionManager, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: SubscriptionManager });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: SubscriptionManager, decorators: [{
type: Injectable
}] });
/**
* Type guard to check if a value is a Subscription
*/
function isSubscription(value) {
return value && typeof value.unsubscribe === 'function';
}
/**
* Type guard to check if a value is an Observable
*/
function isObservableValue(value) {
return isObservable(value);
}
/**
* Utility function to safely unsubscribe from a subscription
* without throwing errors if the subscription is null, undefined, or already closed.
*
* @param subscription The subscription to unsubscribe from
* @returns True if unsubscription was successful, false otherwise
*
* @example
* ```typescript
* let sub: Subscription | undefined;
* // ... later
* safeUnsubscribe(sub); // Won't throw if sub is undefined
* ```
*/
function safeUnsubscribe(subscription) {
if (subscription && !subscription.closed) {
try {
subscription.unsubscribe();
return true;
}
catch (error) {
console.warn('Error during unsubscription:', error);
return false;
}
}
return false;
}
/**
* Utility function to create an observable that automatically unsubscribes
* when the component is destroyed. This is a functional approach alternative
* to using the operator in a pipe.
*
* @param source$ The source observable
* @param destroyRef Optional DestroyRef instance
* @returns A new observable that will complete on component destruction
*
* @example
* ```typescript
* const managedObservable$ = createManagedObservable(
* this.dataService.getData(),
* inject(DestroyRef)
* );
*
* managedObservable$.subscribe(data => console.log(data));
* ```
*/
function createManagedObservable(source$, destroyRef) {
return source$.pipe(takeUntilDestroyed(destroyRef));
}
/**
* Utility function to handle multiple observables with automatic cleanup.
* Returns an array of managed observables.
*
* @param observables Array of source observables
* @param destroyRef Optional DestroyRef instance
* @returns Array of managed observables
*
* @example
* ```typescript
* const [data1$, data2$, data3$] = manageManyObservables([
* this.service.getData1(),
* this.service.getData2(),
* this.service.getData3()
* ]);
* ```
*/
function manageManyObservables(observables, destroyRef) {
return observables.map(obs => createManagedObservable(obs, destroyRef));
}
/**
* Utility class for debugging subscription lifecycle in development
*/
class SubscriptionDebugger {
static defaultOptions = {
enableLogging: false,
logPrefix: '[ng-terminus]',
captureStackTrace: false
};
static options = { ...SubscriptionDebugger.defaultOptions };
/**
* Configure global debugging options
*/
static configure(options) {
SubscriptionDebugger.options = {
...SubscriptionDebugger.defaultOptions,
...options
};
}
/**
* Log subscription creation
*/
static logSubscription(context, subscription) {
if (!SubscriptionDebugger.options.enableLogging)
return;
const prefix = SubscriptionDebugger.options.logPrefix;
console.log(`${prefix} Subscription created in ${context}`, {
subscription,
timestamp: new Date().toISOString(),
...(SubscriptionDebugger.options.captureStackTrace && {
stack: new Error().stack
})
});
}
/**
* Log subscription cleanup
*/
static logCleanup(context, count) {
if (!SubscriptionDebugger.options.enableLogging)
return;
const prefix = SubscriptionDebugger.options.logPrefix;
console.log(`${prefix} Cleaned up ${count} subscription(s) in ${context}`, {
timestamp: new Date().toISOString()
});
}
}
class SubscriptionDebuggerService {
subscriptions = new Map();
debugCounter = 0;
isEnabled = false;
performanceObserver;
memoryInterval;
constructor() {
this.setupPerformanceMonitoring();
this.setupMemoryMonitoring();
}
/**
* Enable debugging mode
*/
enable() {
this.isEnabled = true;
console.log('🔍 SubscriptionDebugger enabled');
}
/**
* Disable debugging mode
*/
disable() {
this.isEnabled = false;
console.log('🔍 SubscriptionDebugger disabled');
}
/**
* Create a debuggable subscription with detailed tracking
*/
debugSubscription(source, options = {}) {
if (!this.isEnabled) {
return source;
}
const id = `debug_${++this.debugCounter}`;
const info = {
id,
componentName: options.componentName,
operatorName: options.name || 'Unknown',
createdAt: new Date(),
isActive: true,
emissionCount: 0,
errorCount: 0,
stackTrace: options.captureStackTrace ? this.captureStackTrace() : undefined
};
this.subscriptions.set(id, info);
return source.pipe(tap({
next: (value) => {
info.emissionCount++;
info.lastEmission = new Date();
if (options.logEmissions) {
console.log(`📦 [${id}] ${info.operatorName}:`, value);
}
},
error: (error) => {
info.errorCount++;
info.lastError = new Date();
if (options.logErrors) {
console.error(`❌ [${id}] ${info.operatorName} Error:`, error);
}
},
complete: () => {
if (options.logEmissions) {
console.log(`✅ [${id}] ${info.operatorName} completed`);
}
}
}), finalize(() => {
info.isActive = false;
info.duration = Date.now() - info.createdAt.getTime();
// Clean up after some time to prevent memory leaks
setTimeout(() => {
this.subscriptions.delete(id);
}, 60000); // Keep debug info for 1 minute after completion
}), share());
}
/**
* Get detailed information about a specific subscription
*/
getSubscriptionInfo(id) {
return this.subscriptions.get(id);
}
/**
* Get all active subscriptions
*/
getActiveSubscriptions() {
return Array.from(this.subscriptions.values()).filter(info => info.isActive);
}
/**
* Get all subscriptions (active and completed)
*/
getAllSubscriptions() {
return Array.from(this.subscriptions.values());
}
/**
* Get performance metrics
*/
getPerformanceMetrics() {
const all = this.getAllSubscriptions();
const active = this.getActiveSubscriptions();
return {
totalSubscriptions: all.length,
activeSubscriptions: active.length,
totalEmissions: all.reduce((sum, info) => sum + info.emissionCount, 0),
totalErrors: all.reduce((sum, info) => sum + info.errorCount, 0),
averageLifetime: this.calculateAverageLifetime(all),
memoryUsage: this.getCurrentMemoryUsage(),
leaksDetected: this.detectPotentialLeaks()
};
}
/**
* Log current subscription status
*/
logStatus() {
if (!this.isEnabled) {
console.log('🔍 SubscriptionDebugger is disabled');
return;
}
const metrics = this.getPerformanceMetrics();
const active = this.getActiveSubscriptions();
console.group('🔍 Subscription Debugger Status');
console.log('📊 Metrics:', metrics);
if (active.length > 0) {
console.group('🔴 Active Subscriptions:');
active.forEach(info => {
console.log(`[${info.id}] ${info.operatorName} (${info.componentName || 'Unknown'})`, {
emissions: info.emissionCount,
errors: info.errorCount,
age: Date.now() - info.createdAt.getTime(),
stackTrace: info.stackTrace
});
});
console.groupEnd();
}
if (metrics.leaksDetected > 0) {
console.warn(`⚠️ Potential memory leaks detected: ${metrics.leaksDetected}`);
}
console.groupEnd();
}
/**
* Detect potential memory leaks
*/
detectPotentialLeaks() {
const active = this.getActiveSubscriptions();
const now = Date.now();
return active.filter(info => {
const age = now - info.createdAt.getTime();
// Consider subscriptions older than 5 minutes with no recent activity as potential leaks
return age > 300000 && (!info.lastEmission || (now - info.lastEmission.getTime()) > 60000);
}).length;
}
/**
* Clear all debug information
*/
clear() {
this.subscriptions.clear();
console.log('🧹 Subscription debug information cleared');
}
/**
* Export debug information as JSON
*/
exportDebugInfo() {
const data = {
timestamp: new Date().toISOString(),
metrics: this.getPerformanceMetrics(),
subscriptions: this.getAllSubscriptions()
};
return JSON.stringify(data, null, 2);
}
captureStackTrace() {
const error = new Error();
return error.stack || 'Stack trace not available';
}
calculateAverageLifetime(subscriptions) {
const completed = subscriptions.filter(info => !info.isActive && info.duration);
if (completed.length === 0)
return 0;
const total = completed.reduce((sum, info) => sum + (info.duration || 0), 0);
return total / completed.length;
}
getCurrentMemoryUsage() {
if (typeof performance !== 'undefined' && 'memory' in performance) {
return performance.memory.usedJSHeapSize;
}
return 0;
}
setupPerformanceMonitoring() {
if (typeof PerformanceObserver !== 'undefined') {
this.performanceObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
// Process performance entries if needed
});
try {
this.performanceObserver.observe({ entryTypes: ['measure', 'navigation'] });
}
catch (error) {
console.warn('Performance monitoring not available:', error);
}
}
}
setupMemoryMonitoring() {
if (this.isEnabled && typeof performance !== 'undefined' && 'memory' in performance) {
this.memoryInterval = setInterval(() => {
const memory = performance.memory;
if (memory.usedJSHeapSize > memory.jsHeapSizeLimit * 0.9) {
console.warn('⚠️ High memory usage detected:', {
used: memory.usedJSHeapSize,
limit: memory.jsHeapSizeLimit,
percentage: (memory.usedJSHeapSize / memory.jsHeapSizeLimit) * 100
});
}
}, 30000); // Check every 30 seconds
}
}
ngOnDestroy() {
if (this.performanceObserver) {
this.performanceObserver.disconnect();
}
if (this.memoryInterval) {
clearInterval(this.memoryInterval);
}
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: SubscriptionDebuggerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: SubscriptionDebuggerService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: SubscriptionDebuggerService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root'
}]
}], ctorParameters: () => [] });
/**
* Service to manage HTTP request cancellation
*/
class HttpRequestManager {
pendingRequests = new Map();
requestCounter = 0;
/**
* Create a cancellable HTTP request
*/
createCancellableRequest(requestFn, requestId) {
const id = requestId || `req_${++this.requestCounter}`;
const cancelSubject = new Subject();
this.pendingRequests.set(id, cancelSubject);
const request$ = requestFn().pipe(takeUntil$1(cancelSubject), catchError((error) => {
if (error.name === 'AbortError') {
console.log(`Request ${id} was cancelled`);
return throwError(() => new Error('Request cancelled'));
}
return throwError(() => error);
}), finalize(() => {
this.pendingRequests.delete(id);
}));
const cancel = () => {
cancelSubject.next();
cancelSubject.complete();
this.pendingRequests.delete(id);
};
return { request$, cancel };
}
/**
* Cancel a specific request by ID
*/
cancelRequest(requestId) {
const cancelSubject = this.pendingRequests.get(requestId);
if (cancelSubject) {
cancelSubject.next();
cancelSubject.complete();
this.pendingRequests.delete(requestId);
}
}
/**
* Cancel all pending requests
*/
cancelAllRequests() {
this.pendingRequests.forEach((cancelSubject) => {
cancelSubject.next();
cancelSubject.complete();
});
this.pendingRequests.clear();
}
/**
* Get the number of pending requests
*/
getPendingRequestCount() {
return this.pendingRequests.size;
}
/**
* Get all pending request IDs
*/
getPendingRequestIds() {
return Array.from(this.pendingRequests.keys());
}
}
/**
* RxJS operator that cancels HTTP requests when component is destroyed
*/
function cancelOnDestroy() {
return function (source) {
const destroyRef = inject(DestroyRef, { optional: true });
if (!destroyRef) {
console.warn('cancelOnDestroy: DestroyRef not available, operator will have no effect');
return source;
}
const destroy$ = new Subject();
destroyRef.onDestroy(() => {
destroy$.next();
destroy$.complete();
});
return source.pipe(takeUntil$1(destroy$));
};
}
/**
* RxJS operator that cancels previous HTTP requests when a new one is made
*/
function cancelPrevious() {
let currentRequest$ = null;
return function (source) {
// Cancel previous request
if (currentRequest$) {
currentRequest$.next();
currentRequest$.complete();
}
// Create new cancellation subject
currentRequest$ = new Subject();
const cancelSubject = currentRequest$;
return source.pipe(takeUntil$1(cancelSubject), finalize(() => {
if (currentRequest$ === cancelSubject) {
currentRequest$ = null;
}
}));
};
}
/**
* RxJS operator that adds retry logic with exponential backoff for HTTP requests
*/
function retryWithBackoff(maxRetries = 3, initialDelay = 1000, maxDelay = 30000) {
return function (source) {
return source.pipe(catchError((error, caught) => {
if (maxRetries <= 0) {
return throwError(() => error);
}
const delayTime = Math.min(initialDelay * Math.pow(2, 3 - maxRetries), maxDelay);
return new Observable(subscriber => {
const timeoutId = setTimeout(() => {
const retriedObservable = caught.pipe(retryWithBackoff(maxRetries - 1, initialDelay, maxDelay));
retriedObservable.subscribe(subscriber);
}, delayTime);
return () => clearTimeout(timeoutId);
});
}));
};
}
/**
* RxJS operator that logs HTTP request lifecycle events
*/
function logHttpRequests(requestName) {
return function (source) {
const name = requestName || 'HTTP Request';
return source.pipe(tap({
subscribe: () => console.log(`🚀 ${name}: Started`),
next: (value) => console.log(`📦 ${name}: Received data`, value),
error: (error) => console.error(`❌ ${name}: Error`, error),
complete: () => console.log(`✅ ${name}: Completed`)
}));
};
}
/**
* RxJS operator that automatically unsubscribes from form value changes when component is destroyed
*/
function takeUntilFormDestroyed() {
return function (source) {
const destroyRef = inject(DestroyRef, { optional: true });
if (!destroyRef) {
console.warn('takeUntilFormDestroyed: DestroyRef not available, operator will have no effect');
return source;
}
const destroy$ = new Subject();
destroyRef.onDestroy(() => {
destroy$.next();
destroy$.complete();
});
return source.pipe(takeUntil$1(destroy$));
};
}
/**
* RxJS operator that emits only when form is valid
*/
function takeWhileFormValid(isValid) {
return function (source) {
return source.pipe(filter(() => isValid()));
};
}
/**
* Injectable service for managing form subscriptions
*/
class FormSubscriptionManager {
subscriptions = new Map();
/**
* Create a managed subscription with automatic cleanup
*/
manage(source, name) {
const destroy$ = new Subject();
this.subscriptions.set(name, destroy$);
return source.pipe(takeUntil$1(destroy$));
}
/**
* Unsubscribe from a specific managed subscription
*/
unsubscribe(name) {
const destroy$ = this.subscriptions.get(name);
if (destroy$) {
destroy$.next();
destroy$.complete();
this.subscriptions.delete(name);
}
}
/**
* Unsubscribe from all managed subscriptions
*/
unsubscribeAll() {
this.subscriptions.forEach(destroy$ => {
destroy$.next();
destroy$.complete();
});
this.subscriptions.clear();
}
/**
* Get count of active subscriptions
*/
getActiveCount() {
return this.subscriptions.size;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: FormSubscriptionManager, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: FormSubscriptionManager });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: FormSubscriptionManager, decorators: [{
type: Injectable
}] });
/**
* The main Angular module for ng-terminus library.
*
* This module can be imported into your Angular application to provide
* all ng-terminus services and enable comprehensive subscription management.
*
* @example
* ```typescript
* import { NgModule } from '@angular/core';
* import { NgTerminusModule } from '@fivexlabs/ng-terminus';
*
* @NgModule({
* imports: [NgTerminusModule.forRoot({ enableDebugger: true })],
* // ...
* })
* export class AppModule { }
* ```
*/
class NgTerminusModule {
/**
* Use this method to configure the module for the root application.
*
* @param config Configuration options for ng-terminus
* @returns The configured module with providers
*
* @example
* ```typescript
* @NgModule({
* imports: [NgTerminusModule.forRoot({
* enableDebugger: true,
* enableMemoryOptimization: true,
* debugMode: environment.production === false
* })],
* // ...
* })
* export class AppModule { }
* ```
*/
static forRoot(config = {}) {
return {
ngModule: NgTerminusModule,
providers: [
SubscriptionManager,
SubscriptionDebuggerService,
HttpRequestManager,
FormSubscriptionManager,
{
provide: 'NG_TERMINUS_CONFIG',
useValue: config
}
]
};
}
/**
* Use this method to configure the module for feature modules.
* This ensures proper service scoping in lazy-loaded modules.
*
* @param config Optional configuration for feature modules
* @returns The configured module
*
* @example
* ```typescript
* @NgModule({
* imports: [NgTerminusModule.forFeature()],
* // ...
* })
* export class FeatureModule { }
* ```
*/
static forFeature(config = {}) {
return {
ngModule: NgTerminusModule,
providers: [
// Feature modules get their own instances of these services
SubscriptionManager,
HttpRequestManager,
FormSubscriptionManager
]
};
}
constructor() {
// Initialize services if configuration is provided
// This would typically be done in an APP_INITIALIZER
console.log('🎯 NgTerminus module initialized');
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: NgTerminusModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.0.2", ngImport: i0, type: NgTerminusModule });
static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: NgTerminusModule, providers: [
SubscriptionManager,
SubscriptionDebuggerService,
HttpRequestManager,
FormSubscriptionManager
] });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: NgTerminusModule, decorators: [{
type: NgModule,
args: [{
providers: [
SubscriptionManager,
SubscriptionDebuggerService,
HttpRequestManager,
FormSubscriptionManager
]
}]
}], ctorParameters: () => [] });
/**
* RxJS operator that automatically unsubscribes when navigating away from the current route.
* This is useful for subscriptions that should only remain active while on a specific route.
*
* @param targetRoute Optional specific route to monitor. If not provided, monitors any route change.
* @returns MonoTypeOperatorFunction that unsubscribes on route change
*
* @example
* ```typescript
* // Unsubscribe on any route change
* this.dataService.getData()
* .pipe(takeUntilRoute())
* .subscribe(data => console.log(data));
*
* // Unsubscribe only when leaving specific route
* this.dataService.getData()
* .pipe(takeUntilRoute('/dashboard'))
* .subscribe(data => console.log(data));
* ```
*/
function takeUntilRoute(targetRoute) {
return function (source) {
const router = inject(Router, { optional: true });
if (!router) {
console.warn('takeUntilRoute: Router not available, operator will have no effect');
return source;
}
const routeChange$ = router.events.pipe(filter(event => event instanceof NavigationEnd), filter((event) => {
if (!targetRoute) {
return true; // Unsubscribe on any route change
}
// Unsubscribe when leaving the target route
return !event.urlAfterRedirects.startsWith(targetRoute);
}));
return source.pipe(takeUntil$1(routeChange$));
};
}
/**
* RxJS operator that keeps subscription active only while on a specific route.
* When navigating away, it unsubscribes and returns EMPTY.
* When navigating back, it resubscribes.
*
* @param routePattern The route pattern to match (supports wildcards)
* @returns OperatorFunction that manages subscription based on route presence
*
* @example
* ```typescript
* this.dataService.getLiveData()
* .pipe(takeWhileOnRoute('/dashboard/**'))
* .subscribe(data => console.log('Dashboard data:', data));
* ```
*/
function takeWhileOnRoute(routePattern) {
return function (source) {
const router = inject(Router, { optional: true });
if (!router) {
console.warn('takeWhileOnRoute: Router not available, operator will have no effect');
return source;
}
return router.events.pipe(filter(event => event instanceof NavigationEnd), switchMap((event) => {
const currentUrl = event.urlAfterRedirects;
const matches = matchesRoutePattern(currentUrl, routePattern);
return matches ? source : EMPTY;
}));
};
}
/**
* Utility function to check if a URL matches a route pattern.
* Supports wildcards (* and **).
*/
function matchesRoutePattern(url, pattern) {
// Convert pattern to regex
const regexPattern = pattern
.replace(/\*\*/g, '.*') // ** matches anything including /
.replace(/\*/g, '[^/]*') // * matches anything except /
.replace(/\//g, '\\/'); // Escape forward slashes
const regex = new RegExp(`^${regexPattern}$`);
return regex.test(url);
}
/**
* RxJS operator that pauses subscription when page becomes hidden
* and resumes when page becomes visible again.
*
* @param emitOnResume Whether to emit the last value when resuming (default: true)
* @returns OperatorFunction that manages subscription based on page visibility
*
* @example
* ```typescript
* this.dataService.getLiveData()
* .pipe(takeWhileVisible())
* .subscribe(data => console.log('Received while visible:', data));
* ```
*/
function takeWhileVisible(emitOnResume = true) {
return function (source) {
const document = inject(DOCUMENT, { optional: true });
if (!document) {
console.warn('takeWhileVisible: Document not available, operator will have no effect');
return source;
}
const visibility$ = merge(fromEvent(document, 'visibilitychange'), fromEvent(document, 'blur'), fromEvent(document, 'focus')).pipe(startWith(null), map(() => !document.hidden), distinctUntilChanged(), share());
return visibility$.pipe(switchMap(isVisible => {
if (isVisible) {
return source;
}
else {
return EMPTY;
}
}));
};
}
/**
* RxJS operator that unsubscribes when page becomes hidden
* and doesn't automatically resubscribe when visible again.
*
* @returns MonoTypeOperatorFunction that unsubscribes on page hide
*
* @example
* ```typescript
* this.dataService.getData()
* .pipe(takeUntilHidden())
* .subscribe(data => console.log('Data received:', data));
* ```
*/
function takeUntilHidden() {
return function (source) {
const document = inject(DOCUMENT, { optional: true });
if (!document) {
console.warn('takeUntilHidden: Document not available, operator will have no effect');
return source;
}
const hidden$ = merge(fromEvent(document, 'visibilitychange'), fromEvent(document, 'blur')).pipe(map(() => document.hidden), distinctUntilChanged(), switchMap(isHidden => isHidden ? [true] : EMPTY));
return source.pipe(takeUntil$1(hidden$));
};
}
/**
* RxJS operator that emits only when page is visible
* and buffers emissions while hidden.
*
* @param bufferSize Maximum number of emissions to buffer (default: 10)
* @returns OperatorFunction that buffers emissions while page is hidden
*
* @example
* ```typescript
* this.dataService.getNotifications()
* .pipe(bufferWhileHidden(5))
* .subscribe(notifications => {
* // Receive up to 5 buffered notifications when page becomes visible
* console.log('Notifications:', notifications);
* });
* ```
*/
function bufferWhileHidden(bufferSize = 10) {
return function (source) {
const document = inject(DOCUMENT, { optional: true });
if (!document) {
console.warn('bufferWhileHidden: Document not available, operator will have no effect');
return source.pipe(map(value => [value]));
}
const visibility$ = merge(fromEvent(document, 'visibilitychange'), fromEvent(document, 'focus'), fromEvent(document, 'blur')).pipe(startWith(null), map(() => !document.hidden), distinctUntilChanged(), share());
let buffer = [];
return source.pipe(switchMap(value => {
return visibility$.pipe(map(isVisible => {
if (isVisible) {
// Page is visible, emit buffered values plus current value
buffer.push(value);
const result = buffer.slice(-bufferSize);
buffer = [];
return result;
}
else {
// Page is hidden, add to buffer
buffer.push(value);
if (buffer.length > bufferSize) {
buffer = buffer.slice(-bufferSize);
}
return [];
}
}));
}), switchMap(values => values.length > 0 ? [values] : EMPTY));
};
}
/**
* RxJS operator that throttles emissions when page is not visible
* and resumes normal emission rate when visible.
*
* @param hiddenThrottleMs Throttle time in milliseconds when hidden (default: 30000)
* @returns MonoTypeOperatorFunction that throttles based on visibility
*
* @example
* ```typescript
* this.dataService.getHeartbeat()
* .pipe(throttleWhileHidden(60000)) // Throttle to 1 minute when hidden
* .subscribe(heartbeat => console.log('Heartbeat:', heartbeat));
* ```
*/
function throttleWhileHidden(hiddenThrottleMs = 30000) {
return function (source) {
const document = inject(DOCUMENT, { optional: true });
if (!document) {
console.warn('throttleWhileHidden: Document not available, operator will have no effect');
return source;
}
const visibility$ = merge(fromEvent(document, 'visibilitychange'), fromEvent(document, 'focus'), fromEvent(document, 'blur')).pipe(startWith(null), map(() => !document.hidden), distinctUntilChanged(), share());
let lastEmission = 0;
return source.pipe(switchMap(value => {
return visibility$.pipe(map(isVisible => {
const now = Date.now();
if (isVisible) {
// Always emit when visible
lastEmission = now;
return value;
}
else {
// Throttle when hidden
if (now - lastEmission >= hiddenThrottleMs) {
lastEmission = now;
return value;
}
return null;
}
}));
}), switchMap(value => value !== null ? [value] : EMPTY));
};
}
/**
* Mock subscription manager for testing
*/
class MockSubscriptionManager {
subscriptions = new Map();
subscriptionCount = 0;
add(subscription, name) {
const id = name || `mock_${++this.subscriptionCount}`;
this.subscriptions.set(id, subscription);
return id;
}
remove(id) {
const subscription = this.subscriptions.get(id);
if (subscription) {
subscription.unsubscribe();
this.subscriptions.delete(id);
return true;
}
return false;
}
removeAll() {
this.subscriptions.forEach(sub => sub.unsubscribe());
this.subscriptions.clear();
}
getActiveCount() {
return Array.from(this.subscriptions.values())
.filter(sub => !sub.closed).length;
}
getAllCount() {
return this.subscriptions.size;
}
isActive(id) {
const subscription = this.subscriptions.get(id);
return subscription ? !subscription.closed : false;
}
}
/**
* Test observable that can be controlled for testing subscription behavior
*/
class TestObservable extends Observable {
subject = new Subject();
isCompleted = false;
hasErrored = false;
constructor() {
super(subscriber => {
const subscription = this.subject.subscribe(subscriber);
return () => subscription.unsubscribe();
});
}
/**
* Emit a value to all subscribers
*/
emit(value) {
if (!this.isCompleted && !this.hasErrored) {
this.subject.next(value);
}
}
/**
* Emit multiple values with optional delays
*/
emitSequence(values, delayMs = 0) {
return new Promise((resolve) => {
let index = 0;
const emitNext = () => {
if (index < values.length && !this.isCompleted && !this.hasErrored) {
this.emit(values[index++]);
if (index < values.length) {
setTimeout(emitNext, delayMs);
}
else {
resolve();
}
}
else {
resolve();
}
};
emitNext();
});
}
/**
* Complete the observable
*/
complete() {
if (!this.isCompleted && !this.hasErrored) {
this.isCompleted = true;
this.subject.complete();
}
}
/**
* Emit an error
*/
error(error) {
if (!this.isCompleted && !this.hasErrored) {
this.hasErrored = true;
this.subject.error(error);
}
}
/**
* Get the current state
*/
getState() {
return {
completed: this.isCompleted,
errored: this.hasErrored
};
}
}
/**
* Subscription testing utilities
*/
class SubscriptionTester {
subscriptions = [];
emissionCounts = new Map();
errorCounts = new Map();
completionCounts = new Map();
/**
* Subscribe to an observable with tracking
*/
subscribe(observable, name, options = {}) {
const subscription = observable
.pipe(tap({
next: (value) => {
this.incrementCount(this.emissionCounts, name);
options.onNext?.(value);
},
error: (error) => {
this.incrementCount(this.errorCounts, name);
options.onError?.(error);
},
complete: () => {
this.incrementCount(this.completionCounts, name);
options.onComplete?.();
}
}))
.subscribe();
this.subscriptions.push(subscription);
return subscription;
}
/**
* Get emission count for a named subscription
*/
getEmissionCount(name) {
return this.emissionCounts.get(name) || 0;
}
/**
* Get error count for a named subscription
*/
getErrorCount(name) {
return this.errorCounts.get(name) || 0;
}
/**
* Get completion count for a named subscription
*/
getCompletionCount(name) {
return this.completionCounts.get(name) || 0;
}
/**
* Get total active subscriptions
*/
getActiveSubscriptionCount() {
return this.subscriptions.filter(sub => !sub.closed).length;
}
/**
* Unsubscribe from all tracked subscriptions
*/
unsubscribeAll() {
this.subscriptions.forEach(sub => sub.unsubscribe());
this.subscriptions = [];
}
/**
* Reset all counters
*/
reset() {
this.unsubscribeAll();
this.emissionCounts.clear();
this.errorCounts.clear();
this.completionCounts.clear();
}
/**
* Wait for a specific number of emissions
*/
waitForEmissions(name, count, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const checkEmissions = () => {
if (this.getEmissionCount(name) >= count) {
resolve();
}
else if (Date.now() - startTime > timeoutMs) {
reject(new Error(`Timeout waiting for ${count} emissions from ${name}. Got ${this.getEmissionCount(name)}`));
}
else {
setTimeout(checkEmissions, 10);
}
};
checkEmissions();
});
}
/**
* Wait for completion
*/
waitForCompletion(name, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const checkCompletion = () => {
if (this.getCompletionCount(name) > 0) {
resolve();
}
else if (Date.now() - startTime > timeoutMs) {
reject(new Error(`Timeout waiting for completion of ${name}`));
}
else {
setTimeout(checkCompletion, 10);
}
};
checkCompletion();
});
}
incrementCount(map, key) {
map.set(key, (map.get(key) || 0) + 1);
}
}
/**
* Memory leak detector for testing
*/
class MemoryLeakDetector {
initialMemory;
subscriptions = [];
/**
* Start monitoring memory usage
*/
startMonitoring() {
if (typeof performance !== 'undefined' && 'memory' in performance) {
this.initialMemory = performance.memory.usedJSHeapSize;
}
}
/**
* Add subscription to monitor
*/
track(subscription) {
this.subscriptions.push(subscription);
}
/**
* Check for memory leaks
*/
checkForLeaks() {
const currentMemory = typeof performance !== 'undefined' && 'memory' in performance
? performance.memory.usedJSHeapSize
: 0;
const memoryIncrease = this.initialMemory
? currentMemory - this.initialMemory
: 0;
const activeSubscriptions = this.subscriptions.filter(sub => !sub.closed).length;
return {
hasLeaks: activeSubscriptions > 0 || memoryIncrease > 1000000, // 1MB threshold
memoryIncrease,
activeSubscriptions,
totalSubscriptions: this.subscriptions.length
};
}
/**
* Clean up all tracked subscriptions
*/
cleanup() {
this.subscriptions.forEach(sub => {
if (!sub.closed) {
sub.unsubscribe();
}
});
this.subscriptions = [];
}
}
/**
* Helper functions for creating test scenarios
*/
const TestScenarios = {
/**
* Create an observable that emits values at intervals
*/
createIntervalObservable(intervalMs, maxValues = 10) {
return new Observable(subscriber => {
let count = 0;
const interval = setInterval(() => {
if (count < maxValues) {
subscriber.next(count++);
}
else {
subscriber.complete();
clearInterval(interval);
}
}, intervalMs);
return () => clearInterval(interval);
});
},
/**
* Create an observable that errors after a delay
*/
createErrorObservable(delayMs, error = new Error('Test error')) {
return new Observable(subscriber => {
const timeout = setTimeout(() => {
subscriber.error(error);
}, delayMs);
return () => clearTimeout(timeout);
});
},
/**
* Create an observable that never completes or errors
*/
createInfiniteObservable() {
return new Observable(subscriber => {
let count = 0;
const interval = setInterval(() => {
subscriber.next(count++);
}, 100);
return () => clearInterval(interval);
});
},
/**
* Create an observable that completes immediately
*/
createImmediateCompletionObservable() {
return EMPTY;
},
/**
* Create an observable that emits once then completes
*/
createSingleValueObservable(value, delayMs = 0) {
return new Observable(subscriber => {
const timeout = setTimeout(() => {
subscriber.next(value);
subscriber.complete();
}, delayMs);
return () => clearTimeout(timeout);
});
}
};
/**
* Memory optimizer for RxJS observables
*/
class MemoryOptimizer {
static instance;
observables = new Map();
sharedObservables = new Map();
idCounter = 0;
isEnabled = false;
static getInstance() {
if (!MemoryOptimizer.instance) {
MemoryOptimizer.instance = new MemoryOptimizer();
}
return MemoryOptimizer.instance;
}
/**
* Enable memory optimization tracking
*/
enable() {
this.isEnabled = true;
console.log('🧠 Memory optimizer enabled');
}
/**
* Disable memory optimization tracking
*/
disable() {
this.isEnabled = false;
console.log('🧠 Memory optimizer disabled');
}
/**
* Create a memory-optimized observable with automatic sharing
*/
optimize(source, options = {}) {
if (!this.isEnabled) {
return source;
}
const id = `opt_${++this.idCounter}`;
const metadata = {
id,
createdAt: new Date(),
subscriptionCount: 0,
isShared: options.share || options.shareReplay !== undefined,
lastActivity: new Date(),
memoryEstimate: this.estimateMemoryUsage(source)
};
this.observables.set(id, metadata);
let optimized = source.pipe(tap(() => {
metadata.lastActivity = new Date();
}), finalize(() => {
this.observables.delete(id);
}));
if (options.shareReplay !== undefined) {
optimized = optimized.pipe(shareReplay(options.shareReplay));
this.sharedObservables.set(id, optimized);
}
else if (options.share) {
optimized = optimized.pipe(share());
this.sharedObservables.set(id, optimized);
}
return optimized;
}
/**
* Create a shared observable that automatically cleans up when no subscribers
*/
shareWithCleanup(source, cl