@memberjunction/react-runtime
Version:
Platform-agnostic React component runtime for MemberJunction. Provides core compilation, registry, and execution capabilities for React components in any JavaScript environment.
371 lines (336 loc) • 10.1 kB
text/typescript
/**
* @fileoverview Centralized resource management for React Runtime
* Handles timers, DOM elements, event listeners, and other resources that need cleanup
* @module @memberjunction/react-runtime/utilities
*/
// Type alias for timer IDs that works in both browser and Node.js
export type TimerId = number | NodeJS.Timeout;
export interface ManagedResource {
type: 'timer' | 'interval' | 'animationFrame' | 'eventListener' | 'domElement' | 'observable' | 'reactRoot';
id: string | TimerId;
cleanup: () => void;
metadata?: Record<string, any>;
}
/**
* Environment-agnostic timer functions that work in both browser and Node.js
*/
const getTimerFunctions = () => {
// Check if we're in a browser environment
if (typeof window !== 'undefined' && window.setTimeout) {
return {
setTimeout: window.setTimeout.bind(window),
clearTimeout: window.clearTimeout.bind(window),
setInterval: window.setInterval.bind(window),
clearInterval: window.clearInterval.bind(window),
requestAnimationFrame: window.requestAnimationFrame?.bind(window),
cancelAnimationFrame: window.cancelAnimationFrame?.bind(window)
};
}
// Node.js environment
else if (typeof global !== 'undefined' && global.setTimeout) {
return {
setTimeout: global.setTimeout,
clearTimeout: global.clearTimeout,
setInterval: global.setInterval,
clearInterval: global.clearInterval,
requestAnimationFrame: undefined, // Not available in Node.js
cancelAnimationFrame: undefined
};
}
// Fallback - return no-op functions
else {
const noop = () => {};
const noopWithReturn = () => 0;
return {
setTimeout: noopWithReturn,
clearTimeout: noop,
setInterval: noopWithReturn,
clearInterval: noop,
requestAnimationFrame: undefined,
cancelAnimationFrame: undefined
};
}
};
const timers = getTimerFunctions();
/**
* ResourceManager provides centralized management of resources that need cleanup.
* This prevents memory leaks by ensuring all resources are properly disposed.
*/
export class ResourceManager {
private resources = new Map<string, Set<ManagedResource>>();
private globalResources = new Set<ManagedResource>();
private cleanupCallbacks = new Map<string, (() => void)[]>();
/**
* Register a timeout with automatic cleanup
*/
setTimeout(
componentId: string,
callback: () => void,
delay: number,
metadata?: Record<string, any>
): number {
const id = timers.setTimeout(() => {
this.removeResource(componentId, 'timer', id);
callback();
}, delay);
this.addResource(componentId, {
type: 'timer',
id,
cleanup: () => timers.clearTimeout(id),
metadata
});
return id as any;
}
/**
* Register an interval with automatic cleanup
*/
setInterval(
componentId: string,
callback: () => void,
delay: number,
metadata?: Record<string, any>
): number {
const id = timers.setInterval(callback, delay);
this.addResource(componentId, {
type: 'interval',
id,
cleanup: () => timers.clearInterval(id),
metadata
});
return id as any;
}
/**
* Register an animation frame with automatic cleanup
*/
requestAnimationFrame(
componentId: string,
callback: FrameRequestCallback,
metadata?: Record<string, any>
): TimerId {
if (!timers.requestAnimationFrame) {
// Fallback to setTimeout in non-browser environments
return this.setTimeout(componentId, () => callback(Date.now()), 16, metadata);
}
const id = timers.requestAnimationFrame((time) => {
this.removeResource(componentId, 'animationFrame', id);
callback(time);
});
this.addResource(componentId, {
type: 'animationFrame',
id,
cleanup: () => timers.cancelAnimationFrame?.(id),
metadata
});
return id as any;
}
/**
* Clear a specific timeout
*/
clearTimeout(componentId: string, id: number): void {
timers.clearTimeout(id);
this.removeResource(componentId, 'timer', id);
}
/**
* Clear a specific interval
*/
clearInterval(componentId: string, id: number): void {
timers.clearInterval(id);
this.removeResource(componentId, 'interval', id);
}
/**
* Cancel a specific animation frame
*/
cancelAnimationFrame(componentId: string, id: TimerId): void {
if (timers.cancelAnimationFrame) {
timers.cancelAnimationFrame(id as number);
} else {
// If we fell back to setTimeout, use clearTimeout
timers.clearTimeout(id as any);
}
this.removeResource(componentId, 'animationFrame', id);
}
/**
* Register an event listener with automatic cleanup
*/
addEventListener(
componentId: string,
target: EventTarget,
type: string,
listener: EventListener,
options?: AddEventListenerOptions
): void {
// Only add event listeners if we have a valid EventTarget (browser environment)
if (target && typeof target.addEventListener === 'function') {
target.addEventListener(type, listener, options);
const resourceId = `${type}-${Date.now()}-${Math.random()}`;
this.addResource(componentId, {
type: 'eventListener',
id: resourceId,
cleanup: () => {
if (target && typeof target.removeEventListener === 'function') {
target.removeEventListener(type, listener, options);
}
},
metadata: { target, type, options }
});
}
}
/**
* Register a DOM element that needs cleanup
*/
registerDOMElement(
componentId: string,
element: any, // Use 'any' to avoid HTMLElement type in Node.js
cleanup?: () => void
): void {
// Only register if we're in a browser environment with DOM support
if (typeof document !== 'undefined' && element && element.parentNode) {
const resourceId = `dom-${Date.now()}-${Math.random()}`;
this.addResource(componentId, {
type: 'domElement',
id: resourceId,
cleanup: () => {
if (cleanup) {
cleanup();
}
if (element && element.parentNode && typeof element.parentNode.removeChild === 'function') {
element.parentNode.removeChild(element);
}
},
metadata: { element }
});
}
}
/**
* Register a React root for cleanup
*/
registerReactRoot(
componentId: string,
root: any,
unmountFn: () => void
): void {
this.addResource(componentId, {
type: 'reactRoot',
id: `react-root-${componentId}`,
cleanup: unmountFn,
metadata: { root }
});
}
/**
* Register a generic cleanup callback for a component
*/
registerCleanup(componentId: string, cleanup: () => void): void {
if (!this.cleanupCallbacks.has(componentId)) {
this.cleanupCallbacks.set(componentId, []);
}
this.cleanupCallbacks.get(componentId)!.push(cleanup);
}
/**
* Register a global resource (not tied to a specific component)
*/
registerGlobalResource(resource: ManagedResource): void {
this.globalResources.add(resource);
}
/**
* Add a resource to be managed
*/
private addResource(componentId: string, resource: ManagedResource): void {
if (!this.resources.has(componentId)) {
this.resources.set(componentId, new Set());
}
this.resources.get(componentId)!.add(resource);
}
/**
* Remove a specific resource
*/
private removeResource(
componentId: string,
type: ManagedResource['type'],
id: string | number | NodeJS.Timeout
): void {
const componentResources = this.resources.get(componentId);
if (componentResources) {
const toRemove = Array.from(componentResources).find(
r => r.type === type && r.id === id
);
if (toRemove) {
componentResources.delete(toRemove);
}
}
}
/**
* Clean up all resources for a specific component
*/
cleanupComponent(componentId: string): void {
// Clean up tracked resources
const componentResources = this.resources.get(componentId);
if (componentResources) {
componentResources.forEach(resource => {
try {
resource.cleanup();
} catch (error) {
console.error(`Error cleaning up ${resource.type} resource:`, error);
}
});
this.resources.delete(componentId);
}
// Execute cleanup callbacks
const callbacks = this.cleanupCallbacks.get(componentId);
if (callbacks) {
callbacks.forEach(callback => {
try {
callback();
} catch (error) {
console.error('Error executing cleanup callback:', error);
}
});
this.cleanupCallbacks.delete(componentId);
}
}
/**
* Clean up all global resources
*/
cleanupGlobal(): void {
this.globalResources.forEach(resource => {
try {
resource.cleanup();
} catch (error) {
console.error(`Error cleaning up global ${resource.type} resource:`, error);
}
});
this.globalResources.clear();
}
/**
* Clean up all resources (components and global)
*/
cleanupAll(): void {
// Clean up all component resources
for (const componentId of this.resources.keys()) {
this.cleanupComponent(componentId);
}
// Clean up global resources
this.cleanupGlobal();
}
/**
* Get resource statistics for debugging
*/
getStats(): {
componentCount: number;
resourceCounts: Record<string, number>;
globalResourceCount: number;
} {
const resourceCounts: Record<string, number> = {};
for (const resources of this.resources.values()) {
resources.forEach(resource => {
resourceCounts[resource.type] = (resourceCounts[resource.type] || 0) + 1;
});
}
return {
componentCount: this.resources.size,
resourceCounts,
globalResourceCount: this.globalResources.size
};
}
}
// Singleton instance
export const resourceManager = new ResourceManager();