@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.
569 lines (508 loc) • 19.3 kB
text/typescript
/**
* @fileoverview Component hierarchy registration utilities for MemberJunction React Runtime.
* Provides functionality to register a hierarchy of components from Skip component specifications.
* @module @memberjunction/react-runtime/hierarchy
*/
import {
CompilationResult,
CompileOptions,
RuntimeContext,
CompiledComponent
} from '../types';
import { ComponentCompiler } from '../compiler';
import { ComponentRegistry } from '../registry';
import { ComponentSpec, ComponentStyles } from '@memberjunction/interactive-component-types';
import { UserInfo, Metadata, LogStatus, GetProductionStatus } from '@memberjunction/core';
import { ComponentLibraryEntity } from '@memberjunction/core-entities';
/**
* Result of a hierarchy registration operation
*/
export interface HierarchyRegistrationResult {
success: boolean;
registeredComponents: string[];
errors: ComponentRegistrationError[];
warnings: string[];
/** The fully resolved component specification with all dependencies and libraries */
resolvedSpec?: ComponentSpec;
}
/**
* Error information for component registration
*/
export interface ComponentRegistrationError {
componentName: string;
error: string;
phase: 'compilation' | 'registration' | 'validation';
}
/**
* Options for hierarchy registration
*/
export interface HierarchyRegistrationOptions {
/** Component styles to apply to all components */
styles?: ComponentStyles;
/** Namespace for component registration */
namespace?: string;
/** Version for component registration */
version?: string;
/** Whether to continue on errors */
continueOnError?: boolean;
/** Whether to override existing components */
allowOverride?: boolean;
/**
* Required, metadata for all possible libraries allowed by the system
*/
allLibraries: ComponentLibraryEntity[];
debug?: boolean;
/** Optional user context for fetching from external registries */
contextUser?: UserInfo;
}
/**
* Utility class for registering component hierarchies
*/
export class ComponentHierarchyRegistrar {
constructor(
private compiler: ComponentCompiler,
private registry: ComponentRegistry,
private runtimeContext: RuntimeContext
) {}
/**
* Fetches a component specification from an external registry
*/
private async fetchExternalComponent(
spec: ComponentSpec,
contextUser?: UserInfo
): Promise<ComponentSpec | null> {
try {
const provider = Metadata?.Provider;
if (!provider || !(provider as any).ExecuteGQL) {
console.warn('⚠️ [ComponentHierarchyRegistrar] No GraphQL provider available for external registry fetch');
return null;
}
// Dynamically import the GraphQL client to avoid circular dependencies
const { GraphQLComponentRegistryClient } = await import('@memberjunction/graphql-dataprovider');
const graphQLClient = new GraphQLComponentRegistryClient(provider as any);
const fullSpec = await graphQLClient.GetRegistryComponent({
registryName: spec.registry!,
namespace: spec.namespace || 'Global',
name: spec.name,
version: spec.version || 'latest'
});
if (fullSpec && fullSpec.code) {
if (!GetProductionStatus()) {
LogStatus(`✅ [ComponentHierarchyRegistrar] Fetched external component ${spec.name} with code (${fullSpec.code.length} chars)`);
}
return fullSpec;
} else {
console.warn(`⚠️ [ComponentHierarchyRegistrar] Failed to fetch external component ${spec.name} or no code`);
return null;
}
} catch (error) {
console.error(`❌ [ComponentHierarchyRegistrar] Error fetching external component ${spec.name}:`, error);
return null;
}
}
/**
* Registers a complete component hierarchy from a root specification
* @param rootSpec - The root component specification
* @param options - Registration options
* @returns Registration result with details about success/failures
*/
async registerHierarchy(
rootSpec: ComponentSpec,
options: HierarchyRegistrationOptions
): Promise<HierarchyRegistrationResult> {
// If this is an external registry component without code, fetch it first
let resolvedRootSpec = rootSpec;
if (rootSpec.location === 'registry' && rootSpec.registry && !rootSpec.code) {
if (!GetProductionStatus()) {
LogStatus(`🌐 [ComponentHierarchyRegistrar] Fetching external registry component: ${rootSpec.registry}/${rootSpec.name}`);
}
resolvedRootSpec = await this.fetchExternalComponent(rootSpec, options.contextUser) || rootSpec;
}
const {
styles,
namespace = 'Global',
version = 'v1',
continueOnError = true,
allowOverride = true
} = options;
const registeredComponents: string[] = [];
const errors: ComponentRegistrationError[] = [];
const warnings: string[] = [];
if (!GetProductionStatus()) {
LogStatus('🌳 ComponentHierarchyRegistrar.registerHierarchy:', undefined, {
rootComponent: resolvedRootSpec.name,
hasLibraries: !!(resolvedRootSpec.libraries && resolvedRootSpec.libraries.length > 0),
libraryCount: resolvedRootSpec.libraries?.length || 0
});
}
// PHASE 1: Compile all components first (but defer factory execution)
const compiledMap = new Map<string, CompiledComponent>();
const specMap = new Map<string, ComponentSpec>();
const allLoadedLibraries = new Map<string, any>(); // Track all loaded libraries
// Helper to compile a component without calling its factory
const compileOnly = async (spec: ComponentSpec): Promise<{ success: boolean; error?: ComponentRegistrationError }> => {
if (!spec.code) return { success: true };
try {
// Filter out invalid library entries before compilation
const validLibraries = spec.libraries?.filter(lib => {
if (!lib || typeof lib !== 'object') return false;
if (!lib.name || lib.name === 'unknown' || lib.name === 'null' || lib.name === 'undefined') return false;
if (!lib.globalVariable || lib.globalVariable === 'undefined' || lib.globalVariable === 'null') return false;
return true;
});
const compileOptions: CompileOptions = {
componentName: spec.name,
componentCode: spec.code,
styles,
libraries: validLibraries,
dependencies: spec.dependencies,
allLibraries: options.allLibraries
};
const result = await this.compiler.compile(compileOptions);
if (result.success && result.component) {
compiledMap.set(spec.name, result.component);
specMap.set(spec.name, spec);
// Extract and accumulate loaded libraries from the compilation
if (result.loadedLibraries) {
result.loadedLibraries.forEach((value, key) => {
if (!allLoadedLibraries.has(key)) {
allLoadedLibraries.set(key, value);
if (!GetProductionStatus()) {
LogStatus(`📚 [registerHierarchy] Added library ${key} to accumulated libraries`);
}
}
});
}
return { success: true };
} else {
return {
success: false,
error: {
componentName: spec.name,
error: result.error?.message || 'Unknown compilation error',
phase: 'compilation'
}
};
}
} catch (error) {
return {
success: false,
error: {
componentName: spec.name,
error: error instanceof Error ? error.message : String(error),
phase: 'compilation'
}
};
}
};
// Compile all components in hierarchy
const compileQueue = [resolvedRootSpec];
const visited = new Set<string>();
while (compileQueue.length > 0) {
let spec = compileQueue.shift()!;
if (visited.has(spec.name)) continue;
visited.add(spec.name);
// If this is an external registry component without code, fetch it first
if (spec.location === 'registry' && spec.registry && !spec.code) {
const fetched = await this.fetchExternalComponent(spec, options.contextUser);
if (fetched) {
spec = fetched;
} else {
console.warn(`⚠️ [ComponentHierarchyRegistrar] Could not fetch external component ${spec.name}, skipping`);
continue;
}
}
const result = await compileOnly(spec);
if (!result.success) {
errors.push(result.error!);
if (!continueOnError) {
return { success: false, registeredComponents, errors, warnings, resolvedSpec: resolvedRootSpec };
}
}
if (spec.dependencies) {
compileQueue.push(...spec.dependencies);
}
}
// Add all accumulated libraries to runtime context
if (allLoadedLibraries.size > 0) {
if (!this.runtimeContext.libraries) {
this.runtimeContext.libraries = {};
}
allLoadedLibraries.forEach((value, key) => {
this.runtimeContext.libraries![key] = value;
if (!GetProductionStatus()) {
LogStatus(`✅ [registerHierarchy] Added ${key} to runtime context libraries`);
}
});
}
// PHASE 2: Execute all factories with components available
for (const [name, compiled] of compiledMap) {
const spec = specMap.get(name)!;
// Build components object from all registered components
const components: Record<string, any> = {};
for (const [depName, depCompiled] of compiledMap) {
// Call factory to get ComponentObject, then extract React component
const depObject = depCompiled.factory(this.runtimeContext, styles);
components[depName] = depObject.component;
}
// Now call factory with components available
const componentObject = compiled.factory(this.runtimeContext, styles, components);
// Register in registry
this.registry.register(
spec.name,
componentObject,
spec.namespace || namespace,
version
);
registeredComponents.push(spec.name);
}
return {
success: errors.length === 0,
registeredComponents,
errors,
warnings,
resolvedSpec: resolvedRootSpec
};
}
/**
* Registers a single component from a specification
* @param spec - Component specification
* @param options - Registration options
* @returns Registration result for this component
*/
async registerSingleComponent(
spec: ComponentSpec,
options: {
styles?: ComponentStyles;
namespace?: string;
version?: string;
allowOverride?: boolean;
allLibraries: ComponentLibraryEntity[];
}
): Promise<{ success: boolean; error?: ComponentRegistrationError }> {
const { styles, namespace = 'Global', version = 'v1', allowOverride = true } = options;
try {
// Skip if no component code
if (!spec.code) {
return {
success: true,
error: undefined
};
}
// Check if component already exists
const existingComponent = this.registry.get(spec.name, namespace, version);
if (existingComponent && !allowOverride) {
return {
success: false,
error: {
componentName: spec.name,
error: `Component already registered in ${namespace}/${version}`,
phase: 'registration'
}
};
}
// Filter out invalid library entries before compilation
const validLibraries = spec.libraries?.filter(lib => {
if (!lib || typeof lib !== 'object') return false;
if (!lib.name || lib.name === 'unknown' || lib.name === 'null' || lib.name === 'undefined') return false;
if (!lib.globalVariable || lib.globalVariable === 'undefined' || lib.globalVariable === 'null') return false;
return true;
});
if (!GetProductionStatus()) {
LogStatus(`🔧 Compiling component ${spec.name} with libraries:`, undefined, {
originalCount: spec.libraries?.length || 0,
filteredCount: validLibraries?.length || 0,
libraries: validLibraries?.map(l => l.name) || []
});
}
// Compile the component
const compileOptions: CompileOptions = {
componentName: spec.name,
componentCode: spec.code,
styles,
libraries: validLibraries, // Pass along filtered library dependencies
dependencies: spec.dependencies, // Pass along child component dependencies
allLibraries: options.allLibraries
};
const compilationResult = await this.compiler.compile(compileOptions);
if (!compilationResult.success) {
return {
success: false,
error: {
componentName: spec.name,
error: compilationResult.error?.message || 'Unknown compilation error',
phase: 'compilation'
}
};
}
// Add loaded libraries to runtime context
if (compilationResult.loadedLibraries && compilationResult.loadedLibraries.size > 0) {
if (!this.runtimeContext.libraries) {
this.runtimeContext.libraries = {};
}
compilationResult.loadedLibraries.forEach((value, key) => {
this.runtimeContext.libraries![key] = value;
if (!GetProductionStatus()) {
LogStatus(`✅ [registerSingleComponent] Added ${key} to runtime context libraries`);
}
});
}
// Call the factory to create the ComponentObject
// IMPORTANT: We don't pass components here because child components may not be registered yet
// Components are resolved later when the component is actually rendered
if (!GetProductionStatus()) {
LogStatus(`🏭 Calling factory for ${spec.name} with runtime context:`, undefined, {
hasReact: !!this.runtimeContext.React,
hasReactDOM: !!this.runtimeContext.ReactDOM,
libraryCount: Object.keys(this.runtimeContext.libraries || {}).length
});
}
const componentObject = compilationResult.component!.factory(this.runtimeContext, styles);
// Register the full ComponentObject (not just the React component)
this.registry.register(
spec.name,
componentObject,
spec.namespace || namespace,
version
);
return { success: true };
} catch (error) {
return {
success: false,
error: {
componentName: spec.name,
error: error instanceof Error ? error.message : String(error),
phase: 'registration'
}
};
}
}
/**
* Recursively registers child components
* @param children - Array of child component specifications
* @param options - Registration options
* @param registeredComponents - Array to track registered components
* @param errors - Array to collect errors
* @param warnings - Array to collect warnings
*/
private async registerChildComponents(
children: ComponentSpec[],
options: HierarchyRegistrationOptions,
registeredComponents: string[],
errors: ComponentRegistrationError[],
warnings: string[]
): Promise<void> {
for (const child of children) {
// Register this child
const childResult = await this.registerSingleComponent(child, {
styles: options.styles,
namespace: options.namespace,
version: options.version,
allowOverride: options.allowOverride,
allLibraries: options.allLibraries
});
if (childResult.success) {
if (child.code) {
registeredComponents.push(child.name);
}
} else {
errors.push(childResult.error!);
if (!options.continueOnError) {
return;
}
}
// Register nested children recursively
const nestedChildren = child.dependencies || [];
if (nestedChildren.length > 0) {
await this.registerChildComponents(
nestedChildren,
options,
registeredComponents,
errors,
warnings
);
}
}
}
}
/**
* Convenience function to register a component hierarchy
* @param rootSpec - The root component specification
* @param compiler - Component compiler instance
* @param registry - Component registry instance
* @param runtimeContext - Runtime context with React and other libraries
* @param options - Registration options
* @returns Registration result
*/
export async function registerComponentHierarchy(
rootSpec: ComponentSpec,
compiler: ComponentCompiler,
registry: ComponentRegistry,
runtimeContext: RuntimeContext,
options: HierarchyRegistrationOptions
): Promise<HierarchyRegistrationResult> {
const registrar = new ComponentHierarchyRegistrar(compiler, registry, runtimeContext);
return registrar.registerHierarchy(rootSpec, options);
}
/**
* Validates a component specification before registration
* @param spec - Component specification to validate
* @returns Array of validation errors (empty if valid)
*/
export function validateComponentSpec(spec: ComponentSpec): string[] {
const errors: string[] = [];
if (!spec.name) {
errors.push('Component specification must have a name');
}
// If componentCode is provided, do basic validation
if (spec.code) {
if (typeof spec.code !== 'string') {
errors.push(`Component code for ${spec.name} must be a string`);
}
if (spec.code.trim().length === 0) {
errors.push(`Component code for ${spec.name} cannot be empty`);
}
}
// Validate child components recursively
const children = spec.dependencies || [];
children.forEach((child, index) => {
const childErrors = validateComponentSpec(child);
childErrors.forEach(error => {
errors.push(`Child ${index} (${child.name || 'unnamed'}): ${error}`);
});
});
return errors;
}
/**
* Flattens a component hierarchy into a list of all components
* @param rootSpec - The root component specification
* @returns Array of all component specifications in the hierarchy
*/
export function flattenComponentHierarchy(rootSpec: ComponentSpec): ComponentSpec[] {
const components: ComponentSpec[] = [rootSpec];
const children = rootSpec.dependencies || [];
children.forEach(child => {
components.push(...flattenComponentHierarchy(child));
});
return components;
}
/**
* Counts the total number of components in a hierarchy
* @param rootSpec - The root component specification
* @param includeEmpty - Whether to include components without code
* @returns Total component count
*/
export function countComponentsInHierarchy(
rootSpec: ComponentSpec,
includeEmpty: boolean = false
): number {
let count = 0;
if (includeEmpty || rootSpec.code) {
count = 1;
}
const children = rootSpec.dependencies || [];
children.forEach(child => {
count += countComponentsInHierarchy(child, includeEmpty);
});
return count;
}