UNPKG

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

817 lines (712 loc) 27.2 kB
/** * @fileoverview Library loading utilities for React runtime * Provides methods to load and manage external JavaScript libraries and CSS * @module @memberjunction/react-runtime/utilities */ import { StandardLibraries, StandardLibraryManager } from './standard-libraries'; import { LibraryConfiguration, ExternalLibraryConfig, LibraryLoadOptions as ConfigLoadOptions } from '../types/library-config'; import { getCoreRuntimeLibraries, isCoreRuntimeLibrary } from './core-libraries'; import { resourceManager } from './resource-manager'; import { ComponentLibraryEntity } from '@memberjunction/core-entities'; import { LibraryDependencyResolver } from './library-dependency-resolver'; import { LoadedLibraryState, DependencyResolutionOptions } from '../types/dependency-types'; import { LibraryRegistry } from './library-registry'; // Unique component ID for resource tracking const LIBRARY_LOADER_COMPONENT_ID = 'mj-react-runtime-library-loader-singleton'; /** * Represents a loaded script or CSS resource */ interface LoadedResource { element: HTMLScriptElement | HTMLLinkElement; promise: Promise<any>; } /** * Options for loading libraries * @deprecated Use LibraryLoadOptions from library-config instead */ export interface LibraryLoadOptions { /** Load core libraries (lodash, d3, Chart.js, dayjs) */ loadCore?: boolean; /** Load UI libraries (antd, React Bootstrap) */ loadUI?: boolean; /** Load CSS files for UI libraries */ loadCSS?: boolean; /** Custom library URLs to load */ customLibraries?: { url: string; globalName: string }[]; } /** * Result of loading libraries */ export interface LibraryLoadResult { React: any; ReactDOM: any; Babel: any; libraries: StandardLibraries; } /** * Library loader class for managing external script loading */ export class LibraryLoader { private static loadedResources = new Map<string, LoadedResource>(); private static loadedLibraryStates = new Map<string, LoadedLibraryState>(); private static dependencyResolver = new LibraryDependencyResolver({ debug: false }); /** * Enable progressive delay for library initialization (useful for test harness) */ public static enableProgressiveDelay: boolean = false; /** * Load all standard libraries (core + UI + CSS) * This is the main method that should be used by test harness and Angular wrapper * @param config Optional full library configuration to replace the default * @param additionalLibraries Optional additional libraries to merge with the configuration * @param options Optional options including debug mode flag */ static async loadAllLibraries( config?: LibraryConfiguration, additionalLibraries?: ExternalLibraryConfig[], options?: { debug?: boolean } ): Promise<LibraryLoadResult> { if (config) { StandardLibraryManager.setConfiguration(config); } // If additional libraries are provided, merge them with the current configuration if (additionalLibraries && additionalLibraries.length > 0) { const currentConfig = StandardLibraryManager.getConfiguration(); const mergedConfig: LibraryConfiguration = { libraries: [...currentConfig.libraries, ...additionalLibraries], metadata: { ...currentConfig.metadata, lastUpdated: new Date().toISOString() } }; StandardLibraryManager.setConfiguration(mergedConfig); } return this.loadLibrariesFromConfig(undefined, options?.debug); } /** * Load libraries based on the current configuration */ static async loadLibrariesFromConfig(options?: ConfigLoadOptions, debug?: boolean): Promise<LibraryLoadResult> { // Always load core runtime libraries first const coreLibraries = getCoreRuntimeLibraries(debug); const corePromises = coreLibraries.map(lib => this.loadScript(lib.cdnUrl, lib.globalVariable, debug) ); const coreResults = await Promise.all(corePromises); const React = coreResults.find((_, i) => coreLibraries[i].globalVariable === 'React'); const ReactDOM = coreResults.find((_, i) => coreLibraries[i].globalVariable === 'ReactDOM'); const Babel = coreResults.find((_, i) => coreLibraries[i].globalVariable === 'Babel'); // Expose React and ReactDOM as globals for UMD libraries that expect them // Many React component libraries (Recharts, Victory, etc.) expect these as globals if (typeof window !== 'undefined') { if (React && !(window as any).React) { (window as any).React = React; console.log('✓ Exposed React as window.React for UMD compatibility'); } if (ReactDOM && !(window as any).ReactDOM) { (window as any).ReactDOM = ReactDOM; console.log('✓ Exposed ReactDOM as window.ReactDOM for UMD compatibility'); } // Also expose PropTypes as empty object if not present (for older libraries) if (!(window as any).PropTypes) { (window as any).PropTypes = {}; console.log('✓ Exposed empty PropTypes as window.PropTypes for UMD compatibility'); } } // Now load plugin libraries from configuration const config = StandardLibraryManager.getConfiguration(); const enabledLibraries = StandardLibraryManager.getEnabledLibraries(); // Filter out any core runtime libraries from plugin configuration let pluginLibraries = enabledLibraries.filter(lib => !isCoreRuntimeLibrary(lib.id)); // Apply options filters if provided if (options) { if (options.categories) { pluginLibraries = pluginLibraries.filter(lib => options.categories!.includes(lib.category) ); } if (options.excludeRuntimeOnly) { pluginLibraries = pluginLibraries.filter(lib => !lib.isRuntimeOnly); } } // Load CSS files for plugin libraries (non-blocking) pluginLibraries.forEach(lib => { if (lib.cdnCssUrl) { this.loadCSS(lib.cdnCssUrl); } }); // Load plugin libraries const pluginPromises = pluginLibraries.map(lib => this.loadScript(lib.cdnUrl, lib.globalVariable, debug) ); const pluginResults = await Promise.all(pluginPromises); // Build libraries object (only contains plugin libraries) const libraries: StandardLibraries = {}; pluginLibraries.forEach((lib, index) => { libraries[lib.globalVariable] = pluginResults[index]; }); return { React: React || (window as any).React, ReactDOM: ReactDOM || (window as any).ReactDOM, Babel: Babel || (window as any).Babel, libraries }; } /** * Load libraries with specific options (backward compatibility) * @deprecated Use loadLibrariesFromConfig instead */ static async loadLibraries(options: LibraryLoadOptions): Promise<LibraryLoadResult> { const { loadCore = true, loadUI = true, loadCSS = true, customLibraries = [] } = options; // Map old options to new configuration approach const categoriesToLoad: Array<ExternalLibraryConfig['category']> = ['runtime']; if (loadCore) { categoriesToLoad.push('utility', 'charting'); } if (loadUI) { categoriesToLoad.push('ui'); } const result = await this.loadLibrariesFromConfig({ categories: categoriesToLoad }); // Load custom libraries if provided if (customLibraries.length > 0) { const customPromises = customLibraries.map(({ url, globalName }) => this.loadScript(url, globalName) ); const customResults = await Promise.all(customPromises); customLibraries.forEach(({ globalName }, index) => { result.libraries[globalName] = customResults[index]; }); } return result; } /** * Load a script from URL */ private static async loadScript(url: string, globalName: string, debug: boolean = false): Promise<any> { // Check if already loaded const existing = this.loadedResources.get(url); if (existing) { if (debug) { console.log(`✅ Library '${globalName}' already loaded (cached)`); } return existing.promise; } const promise = new Promise((resolve, reject) => { // Check if global already exists const existingGlobal = (window as any)[globalName]; if (existingGlobal) { if (debug) { console.log(`✅ Library '${globalName}' already available globally`); } resolve(existingGlobal); return; } // Check if script tag exists const existingScript = document.querySelector(`script[src="${url}"]`); if (existingScript) { this.waitForScriptLoad(existingScript as HTMLScriptElement, globalName, resolve, reject); return; } // Create new script const script = document.createElement('script'); script.src = url; script.async = true; script.crossOrigin = 'anonymous'; const cleanup = () => { script.removeEventListener('load', onLoad); script.removeEventListener('error', onError); }; const onLoad = async () => { cleanup(); // Use progressive delay if enabled, otherwise use original behavior if (LibraryLoader.enableProgressiveDelay) { try { const global = await LibraryLoader.waitForGlobalVariable(globalName, url, debug); resolve(global); } catch (error) { reject(error); } } else { // Original behavior const global = (window as any)[globalName]; if (global) { if (debug) { console.log(`✅ Library '${globalName}' loaded successfully from ${url}`); } resolve(global); } else { // Some libraries may take a moment to initialize const timeoutId = resourceManager.setTimeout( LIBRARY_LOADER_COMPONENT_ID, () => { const delayedGlobal = (window as any)[globalName]; if (delayedGlobal) { if (debug) { console.log(`✅ Library '${globalName}' loaded successfully (delayed initialization)`); } resolve(delayedGlobal); } else { reject(new Error(`${globalName} not found after script load`)); } }, 100, { url, globalName } ); } } }; const onError = () => { cleanup(); reject(new Error(`Failed to load script: ${url}`)); }; script.addEventListener('load', onLoad); script.addEventListener('error', onError); if (debug) { console.log(`📦 Loading library '${globalName}' from ${url}...`); } // Note: Browser may show "Could not read source map" warnings for external libraries. // These are harmless and expected when loading minified libraries from CDNs // that reference source maps which aren't available. This doesn't affect functionality. document.head.appendChild(script); // Register the script element for cleanup resourceManager.registerDOMElement(LIBRARY_LOADER_COMPONENT_ID, script); }); this.loadedResources.set(url, { element: document.querySelector(`script[src="${url}"]`)!, promise }); return promise; } /** * Check if a library global variable is properly initialized * Generic check that works for any library */ private static isLibraryReady(globalVariable: any): boolean { if (!globalVariable) { return false; } // For functions, they're ready immediately if (typeof globalVariable === 'function') { return true; } // For objects, check if they have properties (not an empty object) if (typeof globalVariable === 'object') { // Check for non-empty object with enumerable properties const keys = Object.keys(globalVariable); // Some libraries might have only non-enumerable properties, // so also check for common indicators of initialization return keys.length > 0 || Object.getOwnPropertyNames(globalVariable).length > 1 || // > 1 to exclude just constructor globalVariable.constructor !== Object; // Has a custom constructor } // For other types (string, number, etc.), consider them ready return true; } /** * Wait for a global variable to be available with progressive delays * @param globalName The name of the global variable to wait for * @param url The URL of the script (for debugging) * @param debug Whether to log debug information * @returns The global variable once it's available */ private static async waitForGlobalVariable( globalName: string, url: string, debug: boolean = false ): Promise<any> { const delays = [0, 100, 200, 300, 400]; // Total: 1000ms max delay const maxAttempts = delays.length; let totalDelay = 0; for (let attempt = 0; attempt < maxAttempts; attempt++) { // Wait for the specified delay (0ms on first attempt) if (attempt > 0) { const delay = delays[attempt]; if (debug) { console.log(`⏳ Waiting ${delay}ms for ${globalName} to initialize (attempt ${attempt + 1}/${maxAttempts})...`); } await new Promise(resolve => { resourceManager.setTimeout( LIBRARY_LOADER_COMPONENT_ID, () => resolve(undefined), delay, { globalName, attempt } ); }); totalDelay += delay; } // Check if the global variable exists const global = (window as any)[globalName]; if (global) { // Use generic library readiness check const isReady = this.isLibraryReady(global); if (isReady) { if (debug) { if (totalDelay > 0) { console.log(`✅ ${globalName} ready after ${totalDelay}ms delay`); } else { console.log(`✅ Library '${globalName}' loaded successfully from ${url}`); } } return global; } else if (debug && attempt < maxAttempts - 1) { console.log(`🔄 ${globalName} exists but not fully initialized, will retry...`); } } } // Final check after all attempts const finalGlobal = (window as any)[globalName]; if (finalGlobal) { console.warn(`⚠️ ${globalName} loaded but may not be fully initialized after ${totalDelay}ms`); return finalGlobal; } throw new Error(`${globalName} not found after script load and ${totalDelay}ms delay`); } /** * Load CSS from URL */ private static loadCSS(url: string): void { 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); // Register the link element for cleanup resourceManager.registerDOMElement(LIBRARY_LOADER_COMPONENT_ID, link); this.loadedResources.set(url, { element: link, promise: Promise.resolve() }); } /** * Wait for existing script to load */ private static waitForScriptLoad( script: HTMLScriptElement, globalName: string, resolve: (value: any) => void, reject: (reason: any) => void ): void { const checkGlobal = () => { const global = (window as any)[globalName]; if (global) { resolve(global); } else { // Retry after a short delay resourceManager.setTimeout( LIBRARY_LOADER_COMPONENT_ID, () => { const delayedGlobal = (window as any)[globalName]; if (delayedGlobal) { resolve(delayedGlobal); } else { reject(new Error(`${globalName} not found after script load`)); } }, 100, { context: 'waitForScriptLoad', globalName } ); } }; // Check if already loaded if ((script as any).complete || (script as any).readyState === 'complete') { checkGlobal(); return; } // Wait for load const loadHandler = () => { checkGlobal(); }; resourceManager.addEventListener( LIBRARY_LOADER_COMPONENT_ID, script, 'load', loadHandler, { once: true } ); } /** * Get all loaded resources (for cleanup) */ static getLoadedResources(): Map<string, LoadedResource> { return this.loadedResources; } /** * Clear loaded resources cache and cleanup DOM elements */ static clearCache(): void { // Remove all script and link elements we added this.loadedResources.forEach((resource, url) => { if (resource.element && resource.element.parentNode) { resource.element.parentNode.removeChild(resource.element); } }); this.loadedResources.clear(); this.loadedLibraryStates.clear(); // Clean up any resources managed by resource manager resourceManager.cleanupComponent(LIBRARY_LOADER_COMPONENT_ID); } /** * Load a library with its dependencies * @param libraryName - Name of the library to load * @param allLibraries - All available libraries for dependency resolution * @param requestedBy - Name of the component/library requesting this load * @param options - Dependency resolution options * @returns Promise resolving to the loaded library global object */ static async loadLibraryWithDependencies( libraryName: string, allLibraries: ComponentLibraryEntity[], requestedBy: string = 'user', options?: DependencyResolutionOptions ): Promise<any> { const debug = options?.debug || false; if (debug) { console.log(`📚 Loading library '${libraryName}' with dependencies`); } // Check if already loaded const existingState = this.loadedLibraryStates.get(libraryName); if (existingState) { if (debug) { console.log(`✅ Library '${libraryName}' already loaded (version: ${existingState.version})`); } // Track who requested it if (!existingState.requestedBy.includes(requestedBy)) { existingState.requestedBy.push(requestedBy); } return (window as any)[existingState.globalVariable]; } // Get load order including dependencies const loadOrderResult = this.dependencyResolver.getLoadOrder( [libraryName], allLibraries, options ); if (!loadOrderResult.success) { const errors = loadOrderResult.errors?.join(', ') || 'Unknown error'; throw new Error(`Failed to resolve dependencies for '${libraryName}': ${errors}`); } if (loadOrderResult.warnings && debug) { console.warn(`⚠️ Warnings for '${libraryName}':`, loadOrderResult.warnings); } const loadOrder = loadOrderResult.order || []; if (debug) { console.log(`📋 Load order for '${libraryName}':`, loadOrder.map(lib => `${lib.Name}@${lib.Version}`)); } // Load libraries in order for (const library of loadOrder) { // Skip if already loaded if (this.loadedLibraryStates.has(library.Name)) { if (debug) { console.log(`⏭️ Skipping '${library.Name}' (already loaded)`); } continue; } // Check library status if (library.Status) { if (library.Status === 'Disabled') { console.error(`🚫 ERROR: Library '${library.Name}' is DISABLED and should not be used`); // Continue loading anyway per requirements } else if (library.Status === 'Deprecated') { console.warn(`⚠️ WARNING: Library '${library.Name}' is DEPRECATED. Consider using an alternative.`); } // Active status is fine, no message needed } if (debug) { console.log(`📥 Loading '${library.Name}@${library.Version}'`); } // Load the library if (!library.CDNUrl || !library.GlobalVariable) { throw new Error(`Library '${library.Name}' missing CDN URL or global variable`); } // Load CSS if available if (library.CDNCssUrl) { const cssUrls = library.CDNCssUrl.split(',').map(url => url.trim()); for (const cssUrl of cssUrls) { if (cssUrl) { this.loadCSS(cssUrl); } } } // Load the script const loadedGlobal = await this.loadScript(library.CDNUrl, library.GlobalVariable, debug); // Track the loaded state const dependencies = Array.from( this.dependencyResolver.getDirectDependencies(library).keys() ); this.loadedLibraryStates.set(library.Name, { name: library.Name, version: library.Version || 'unknown', globalVariable: library.GlobalVariable, loadedAt: new Date(), requestedBy: library.Name === libraryName ? [requestedBy] : [], dependencies }); if (debug) { console.log(`✅ Loaded '${library.Name}@${library.Version}'`); } } // Return the originally requested library's global const targetLibrary = loadOrder.find(lib => lib.Name === libraryName); if (!targetLibrary || !targetLibrary.GlobalVariable) { throw new Error(`Failed to load library '${libraryName}'`); } return (window as any)[targetLibrary.GlobalVariable]; } /** * Load multiple libraries with dependency resolution * @param libraryNames - Names of libraries to load * @param allLibraries - All available libraries for dependency resolution * @param requestedBy - Name of the component requesting these libraries * @param options - Dependency resolution options * @returns Map of library names to their loaded global objects */ static async loadLibrariesWithDependencies( libraryNames: string[], allLibraries: ComponentLibraryEntity[], requestedBy: string = 'user', options?: DependencyResolutionOptions ): Promise<Map<string, any>> { const debug = options?.debug || false; const result = new Map<string, any>(); if (debug) { console.log(`📚 Loading libraries with dependencies:`, libraryNames); console.log(` 📦 Total available libraries: ${allLibraries.length}`); } // Get combined load order for all requested libraries const loadOrderResult = this.dependencyResolver.getLoadOrder( libraryNames, allLibraries, options ); if (!loadOrderResult.success) { const errors = loadOrderResult.errors?.join(', ') || 'Unknown error'; throw new Error(`Failed to resolve dependencies: ${errors}`); } if (debug) { console.log(` 📊 Dependency resolution result:`, { success: loadOrderResult.success, errors: loadOrderResult.errors || [], warnings: loadOrderResult.warnings || [] }); if (loadOrderResult.order) { console.log(` 🔄 Resolved dependencies for each library:`); loadOrderResult.order.forEach(lib => { const deps = this.dependencyResolver.parseDependencies(lib.Dependencies); if (deps.size > 0) { console.log(` • ${lib.Name}@${lib.Version} requires:`, Array.from(deps.entries())); } else { console.log(` • ${lib.Name}@${lib.Version} (no dependencies)`); } }); } } if (loadOrderResult.warnings && debug) { console.warn(` ⚠️ Warnings:`, loadOrderResult.warnings); } const loadOrder = loadOrderResult.order || []; if (debug) { console.log(` 📋 Final load order:`, loadOrder.map(lib => `${lib.Name}@${lib.Version}`)); } // Load all libraries in order for (const library of loadOrder) { // Skip if already loaded if (this.loadedLibraryStates.has(library.Name)) { if (debug) { console.log(`⏭️ Skipping '${library.Name}' (already loaded)`); } const state = this.loadedLibraryStates.get(library.Name)!; if (libraryNames.includes(library.Name)) { result.set(library.Name, (window as any)[state.globalVariable]); } continue; } // Check library status if (library.Status) { if (library.Status === 'Disabled') { console.error(`🚫 ERROR: Library '${library.Name}' is DISABLED and should not be used`); // Continue loading anyway per requirements } else if (library.Status === 'Deprecated') { console.warn(`⚠️ WARNING: Library '${library.Name}' is DEPRECATED. Consider using an alternative.`); } // Active status is fine, no message needed } if (debug) { console.log(`📥 Loading '${library.Name}@${library.Version}'`); } // Load the library if (!library.CDNUrl || !library.GlobalVariable) { throw new Error(`Library '${library.Name}' missing CDN URL or global variable`); } // Load CSS if available if (library.CDNCssUrl) { const cssUrls = library.CDNCssUrl.split(',').map(url => url.trim()); for (const cssUrl of cssUrls) { if (cssUrl) { this.loadCSS(cssUrl); } } } // Load the script const loadedGlobal = await this.loadScript(library.CDNUrl, library.GlobalVariable, debug); // Track the loaded state const dependencies = Array.from( this.dependencyResolver.getDirectDependencies(library).keys() ); this.loadedLibraryStates.set(library.Name, { name: library.Name, version: library.Version || 'unknown', globalVariable: library.GlobalVariable, loadedAt: new Date(), requestedBy: libraryNames.includes(library.Name) ? [requestedBy] : [], dependencies }); // Add to result if it was directly requested if (libraryNames.includes(library.Name)) { result.set(library.Name, loadedGlobal); } if (debug) { console.log(`✅ Loaded '${library.Name}@${library.Version}'`); } } return result; } /** * Get information about loaded libraries * @returns Map of loaded library states */ static getLoadedLibraryStates(): Map<string, LoadedLibraryState> { return new Map(this.loadedLibraryStates); } /** * Check if a library is loaded * @param libraryName - Name of the library * @returns True if the library is loaded */ static isLibraryLoaded(libraryName: string): boolean { return this.loadedLibraryStates.has(libraryName); } /** * Get the version of a loaded library * @param libraryName - Name of the library * @returns Version string or undefined if not loaded */ static getLoadedLibraryVersion(libraryName: string): string | undefined { return this.loadedLibraryStates.get(libraryName)?.version; } }