UNPKG

@memberjunction/ng-react

Version:

Angular components for hosting React components in MemberJunction applications

344 lines 13.4 kB
/** * @fileoverview Service for loading external scripts and CSS in Angular applications. * Manages the lifecycle of dynamically loaded resources with proper cleanup. * @module @memberjunction/ng-react */ import { Injectable } from '@angular/core'; import { LibraryLoader } from '@memberjunction/react-runtime'; import * as i0 from "@angular/core"; /** * Service for loading external scripts and CSS with proper cleanup. * Provides methods to dynamically load React and related libraries from CDN. */ export class ScriptLoaderService { constructor() { this.loadedResources = new Map(); this.cleanupOnDestroy = new Set(); } ngOnDestroy() { this.cleanup(); } /** * Load a script from URL with automatic cleanup tracking * @param url - Script URL to load * @param globalName - Expected global variable name * @param autoCleanup - Whether to cleanup on service destroy * @returns Promise resolving to the global object */ async loadScript(url, globalName, autoCleanup = false) { const existing = this.loadedResources.get(url); if (existing) { return existing.promise; } const promise = this.createScriptPromise(url, globalName); const element = document.querySelector(`script[src="${url}"]`); if (element) { this.loadedResources.set(url, { element, promise }); if (autoCleanup) { this.cleanupOnDestroy.add(url); } } return promise; } /** * Load a script with additional validation function * @param url - Script URL to load * @param globalName - Expected global variable name * @param validator - Function to validate the loaded object * @param autoCleanup - Whether to cleanup on service destroy * @returns Promise resolving to the validated global object */ async loadScriptWithValidation(url, globalName, validator, autoCleanup = false) { const existing = this.loadedResources.get(url); if (existing) { const obj = await existing.promise; // Re-validate even for cached resources if (!validator(obj)) { throw new Error(`${globalName} loaded but failed validation`); } return obj; } const promise = this.createScriptPromiseWithValidation(url, globalName, validator); const element = document.querySelector(`script[src="${url}"]`); if (element) { this.loadedResources.set(url, { element, promise }); if (autoCleanup) { this.cleanupOnDestroy.add(url); } } return promise; } /** * Load CSS from URL * @param url - CSS URL to load */ loadCSS(url) { if (this.loadedResources.has(url)) { return; } const existingLink = document.querySelector(`link[href="${url}"]`); if (existingLink) { return; } const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = url; document.head.appendChild(link); this.loadedResources.set(url, { element: link, promise: Promise.resolve() }); } /** * Load common React libraries and UI frameworks * @param config Optional library configuration * @param additionalLibraries Optional additional libraries to merge * @param options Optional options including debug flag * @returns Promise resolving to React ecosystem objects */ async loadReactEcosystem(config, additionalLibraries, options) { // Use the new LibraryLoader from react-runtime for consistency const result = await LibraryLoader.loadAllLibraries(config, additionalLibraries, options); // The LibraryLoader handles all the loading, but we need to ensure // ReactDOM.createRoot is available for Angular's specific needs // The ReactBridgeService will handle the delayed validation // Track loaded resources for cleanup LibraryLoader.getLoadedResources().forEach((resource, url) => { this.loadedResources.set(url, resource); }); return result; } /** * Get library name from URL for global variable mapping * @param url - Library URL * @returns Global variable name */ getLibraryNameFromUrl(url) { // Map known URLs to their global variable names if (url.includes('lodash')) return '_'; if (url.includes('d3')) return 'd3'; if (url.includes('Chart.js') || url.includes('chart')) return 'Chart'; if (url.includes('dayjs')) return 'dayjs'; if (url.includes('antd')) return 'antd'; if (url.includes('react-bootstrap')) return 'ReactBootstrap'; if (url.includes('react-dom')) return 'ReactDOM'; if (url.includes('react')) return 'React'; if (url.includes('babel')) return 'Babel'; // Default: extract library name from filename const match = url.match(/\/([^/]+?)(?:\.min)?\.js$/i); return match ? match[1] : 'UnknownLibrary'; } /** * Remove a specific loaded resource * @param url - URL of resource to remove */ removeResource(url) { const resource = this.loadedResources.get(url); if (resource?.element && resource.element.parentNode) { resource.element.parentNode.removeChild(resource.element); } this.loadedResources.delete(url); this.cleanupOnDestroy.delete(url); } /** * Clean up all resources marked for auto-cleanup */ cleanup() { for (const url of this.cleanupOnDestroy) { this.removeResource(url); } this.cleanupOnDestroy.clear(); } /** * Create a promise that resolves when script loads * @param url - Script URL * @param globalName - Expected global variable * @returns Promise resolving to global object */ createScriptPromise(url, globalName) { return new Promise((resolve, reject) => { // Check if already loaded if (window[globalName]) { resolve(window[globalName]); return; } // Check if script tag exists const existingScript = document.querySelector(`script[src="${url}"]`); if (existingScript) { this.waitForScriptLoad(existingScript, globalName, resolve, reject); return; } // Create new script const script = document.createElement('script'); script.src = url; script.async = true; script.onload = () => { const global = window[globalName]; if (global) { resolve(global); } else { reject(new Error(`${globalName} not found after script load`)); } }; script.onerror = () => { reject(new Error(`Failed to load script: ${url}`)); }; document.head.appendChild(script); this.loadedResources.set(url, { element: script, promise: Promise.resolve() }); }); } /** * Create a promise that resolves when script loads and passes validation * @param url - Script URL * @param globalName - Expected global variable * @param validator - Validation function * @returns Promise resolving to validated global object */ createScriptPromiseWithValidation(url, globalName, validator) { return new Promise((resolve, reject) => { // Check if already loaded and valid const existingGlobal = window[globalName]; if (existingGlobal && validator(existingGlobal)) { resolve(existingGlobal); return; } // Check if script tag exists const existingScript = document.querySelector(`script[src="${url}"]`); if (existingScript) { this.waitForScriptLoadWithValidation(existingScript, globalName, validator, resolve, reject); return; } // Create new script const script = document.createElement('script'); script.src = url; script.async = true; script.onload = () => { this.waitForValidation(globalName, validator, resolve, reject); }; script.onerror = () => { reject(new Error(`Failed to load script: ${url}`)); }; document.head.appendChild(script); this.loadedResources.set(url, { element: script, promise: Promise.resolve() }); }); } /** * Wait for global object to be available and valid * @param globalName - Global variable name * @param validator - Validation function * @param resolve - Promise resolve function * @param reject - Promise reject function * @param attempts - Current attempt number * @param maxAttempts - Maximum attempts before failing */ waitForValidation(globalName, validator, resolve, reject, attempts = 0, maxAttempts = 50 // 5 seconds total with 100ms intervals ) { const global = window[globalName]; if (global && validator(global)) { resolve(global); return; } if (attempts >= maxAttempts) { if (global) { reject(new Error(`${globalName} loaded but validation failed after ${maxAttempts} attempts`)); } else { reject(new Error(`${globalName} not found after ${maxAttempts} attempts`)); } return; } // Retry with exponential backoff for first few attempts, then fixed interval const delay = attempts < 5 ? Math.min(100 * Math.pow(1.5, attempts), 500) : 100; setTimeout(() => { this.waitForValidation(globalName, validator, resolve, reject, attempts + 1, maxAttempts); }, delay); } /** * Wait for existing script to load * @param script - Script element * @param globalName - Expected global variable * @param resolve - Promise resolve function * @param reject - Promise reject function */ waitForScriptLoad(script, globalName, resolve, reject) { const checkGlobal = () => { if (window[globalName]) { resolve(window[globalName]); return; } // Give it a moment for the global to be defined setTimeout(() => { if (window[globalName]) { resolve(window[globalName]); } else { reject(new Error(`${globalName} not found after script load`)); } }, 100); }; if ('readyState' in script) { // IE support script.onreadystatechange = () => { if (script.readyState === 'loaded' || script.readyState === 'complete') { script.onreadystatechange = null; checkGlobal(); } }; } else { // Modern browsers const loadHandler = () => { script.removeEventListener('load', loadHandler); checkGlobal(); }; script.addEventListener('load', loadHandler); } } /** * Wait for existing script to load with validation * @param script - Script element * @param globalName - Expected global variable * @param validator - Validation function * @param resolve - Promise resolve function * @param reject - Promise reject function */ waitForScriptLoadWithValidation(script, globalName, validator, resolve, reject) { const checkGlobal = () => { this.waitForValidation(globalName, validator, resolve, reject); }; if ('readyState' in script) { // IE support script.onreadystatechange = () => { if (script.readyState === 'loaded' || script.readyState === 'complete') { script.onreadystatechange = null; checkGlobal(); } }; } else { // Modern browsers const loadHandler = () => { script.removeEventListener('load', loadHandler); checkGlobal(); }; script.addEventListener('load', loadHandler); } } static { this.ɵfac = function ScriptLoaderService_Factory(t) { return new (t || ScriptLoaderService)(); }; } static { this.ɵprov = /*@__PURE__*/ i0.ɵɵdefineInjectable({ token: ScriptLoaderService, factory: ScriptLoaderService.ɵfac, providedIn: 'root' }); } } (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(ScriptLoaderService, [{ type: Injectable, args: [{ providedIn: 'root' }] }], null, null); })(); //# sourceMappingURL=script-loader.service.js.map