UNPKG

@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
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