@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.
390 lines (338 loc) • 11.9 kB
text/typescript
/**
* @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';
// 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>();
/**
* 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
*/
static async loadAllLibraries(
config?: LibraryConfiguration,
additionalLibraries?: ExternalLibraryConfig[]
): 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();
}
/**
* Load libraries based on the current configuration
*/
static async loadLibrariesFromConfig(options?: ConfigLoadOptions): Promise<LibraryLoadResult> {
// Always load core runtime libraries first
const coreLibraries = getCoreRuntimeLibraries();
const corePromises = coreLibraries.map(lib =>
this.loadScript(lib.cdnUrl, lib.globalVariable)
);
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)
);
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): Promise<any> {
// Check if already loaded
const existing = this.loadedResources.get(url);
if (existing) {
return existing.promise;
}
const promise = new Promise((resolve, reject) => {
// Check if global already exists
const existingGlobal = (window as any)[globalName];
if (existingGlobal) {
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 = () => {
cleanup();
const global = (window as any)[globalName];
if (global) {
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) {
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);
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;
}
/**
* 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();
// Clean up any resources managed by resource manager
resourceManager.cleanupComponent(LIBRARY_LOADER_COMPONENT_ID);
}
}