@memberjunction/ng-react
Version:
Angular components for hosting React components in MemberJunction applications
580 lines • 29 kB
JavaScript
/**
* @fileoverview Angular component that hosts React components with proper memory management.
* Provides a bridge between Angular and React ecosystems in MemberJunction applications.
* @module @memberjunction/ng-react
*/
import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { Subject } from 'rxjs';
import { ComponentSpec } from '@memberjunction/interactive-component-types';
import { ReactBridgeService } from '../services/react-bridge.service';
import { AngularAdapterService } from '../services/angular-adapter.service';
import { createErrorBoundary, ComponentHierarchyRegistrar, resourceManager, reactRootManager, SetupStyles } from '@memberjunction/react-runtime';
import { createRuntimeUtilities } from '../utilities/runtime-utilities';
import { LogError, CompositeKey, Metadata, RunView } from '@memberjunction/core';
import { ComponentMetadataEngine } from '@memberjunction/core-entities';
import * as i0 from "@angular/core";
import * as i1 from "../services/react-bridge.service";
import * as i2 from "../services/angular-adapter.service";
const _c0 = ["container"];
function MJReactComponent_Conditional_3_Template(rf, ctx) { if (rf & 1) {
i0.ɵɵelementStart(0, "div", 3)(1, "div", 4);
i0.ɵɵelement(2, "i", 5);
i0.ɵɵelementEnd();
i0.ɵɵelementStart(3, "div", 6);
i0.ɵɵtext(4, "Loading component...");
i0.ɵɵelementEnd()();
} }
/**
* Angular component that hosts React components with proper memory management.
* This component provides a bridge between Angular and React, allowing React components
* to be used seamlessly within Angular applications.
*/
export class MJReactComponent {
set utilities(value) {
this._utilities = value;
}
get utilities() {
// Lazy initialization - only create default utilities when needed
if (!this._utilities) {
const runtimeUtils = createRuntimeUtilities();
this._utilities = runtimeUtils.buildUtilities();
console.log('MJReactComponent: Auto-initialized utilities using createRuntimeUtilities()');
}
return this._utilities;
}
set styles(value) {
this._styles = value;
}
get styles() {
// Lazy initialization - only create default styles when needed
if (!this._styles) {
this._styles = SetupStyles();
console.log('MJReactComponent: Auto-initialized styles using SetupStyles()');
}
return this._styles;
}
set savedUserSettings(value) {
this._savedUserSettings = value || {};
// Re-render if component is initialized
if (this.isInitialized) {
this.renderComponent();
}
}
get savedUserSettings() {
return this._savedUserSettings;
}
constructor(reactBridge, adapter, cdr) {
this.reactBridge = reactBridge;
this.adapter = adapter;
this.cdr = cdr;
this._savedUserSettings = {};
this.stateChange = new EventEmitter();
this.componentEvent = new EventEmitter();
this.refreshData = new EventEmitter();
this.openEntityRecord = new EventEmitter();
this.userSettingsChanged = new EventEmitter();
this.reactRootId = null;
this.compiledComponent = null;
this.destroyed$ = new Subject();
this.currentCallbacks = null;
this.isInitialized = false;
this.isRendering = false;
this.pendingRender = false;
this.isDestroying = false;
this.componentVersion = ''; // Store the version for resolver
this.hasError = false;
// Generate unique component ID for resource tracking
this.componentId = `mj-react-component-${Date.now()}-${Math.random()}`;
}
async ngAfterViewInit() {
// Trigger change detection to show loading state
this.cdr.detectChanges();
await this.initializeComponent();
}
ngOnDestroy() {
// Set destroying flag immediately
this.isDestroying = true;
// Cancel any pending renders
this.pendingRender = false;
this.destroyed$.next();
this.destroyed$.complete();
this.cleanup();
}
/**
* Initialize the React component
*/
async initializeComponent() {
try {
// Ensure React is loaded
await this.reactBridge.getReactContext();
// Wait for React to be fully ready (handles first-load delay)
await this.reactBridge.waitForReactReady();
// Register component hierarchy
await this.registerComponentHierarchy();
// Compile main component with its library dependencies
await ComponentMetadataEngine.Instance.Config(false, Metadata.Provider.CurrentUser);
const result = await this.adapter.compileComponent({
componentName: this.component.name,
componentCode: this.component.code,
styles: this.styles,
libraries: this.component.libraries, // Pass library dependencies from ComponentSpec
allLibraries: ComponentMetadataEngine.Instance.ComponentLibraries
});
if (!result.success) {
throw new Error(result.error?.message || 'Component compilation failed');
}
// Get runtime context and execute component factory
const context = this.adapter.getRuntimeContext();
// Call the factory function to get the component wrapper
// result.component is a CompiledComponent object with a 'component' property that's the factory
const componentWrapper = result.component.component(context, this.styles);
// Validate the component wrapper structure
if (!componentWrapper || typeof componentWrapper !== 'object') {
throw new Error(`Invalid component wrapper returned for ${this.component.name}: ${typeof componentWrapper}`);
}
if (!componentWrapper.component) {
throw new Error(`Component wrapper missing 'component' property for ${this.component.name}`);
}
if (typeof componentWrapper.component !== 'function') {
throw new Error(`Component is not a function for ${this.component.name}: ${typeof componentWrapper.component}`);
}
this.compiledComponent = componentWrapper;
// Create managed React root
const reactContext = this.reactBridge.getCurrentContext();
if (!reactContext) {
throw new Error('React context not available');
}
this.reactRootId = reactRootManager.createRoot(this.container.nativeElement, (container) => reactContext.ReactDOM.createRoot(container), this.componentId);
// Initial render
this.renderComponent();
this.isInitialized = true;
// Trigger change detection since we're using OnPush
this.cdr.detectChanges();
}
catch (error) {
this.hasError = true;
LogError(`Failed to initialize React component: ${error}`);
this.componentEvent.emit({
type: 'error',
payload: {
error: error instanceof Error ? error.message : String(error),
source: 'initialization'
}
});
// Trigger change detection to show error state
this.cdr.detectChanges();
}
}
/**
* Generate a hash from component code for versioning
* Uses a simple hash function that's fast and sufficient for version differentiation
*/
generateComponentHash(spec) {
// Collect all code from the component hierarchy
const codeStrings = [];
const collectCode = (s) => {
if (s.code) {
codeStrings.push(s.code);
}
if (s.dependencies) {
for (const dep of s.dependencies) {
collectCode(dep);
}
}
};
collectCode(spec);
// Generate hash from concatenated code
const fullCode = codeStrings.join('|');
let hash = 0;
for (let i = 0; i < fullCode.length; i++) {
const char = fullCode.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
// Convert to hex string and take first 8 characters for readability
const hexHash = Math.abs(hash).toString(16).padStart(8, '0').substring(0, 8);
return `v${hexHash}`;
}
/**
* Resolve components with specific version
*/
resolveComponentsWithVersion(spec, version, namespace = 'Global') {
const resolved = {};
const registry = this.adapter.getRegistry();
const resolveHierarchy = (s, visited = new Set()) => {
// Prevent circular dependencies
if (visited.has(s.name)) {
console.warn(`Circular dependency detected for component: ${s.name}`);
return;
}
visited.add(s.name);
// Get component with specific version
const component = registry.get(s.name, namespace, version);
if (component) {
resolved[s.name] = component;
console.log(` Resolved ${s.name}@${version}`);
}
else {
console.warn(` ⚠️ Component not found: ${s.name}@${version}`);
}
// Process dependencies
if (s.dependencies) {
for (const dep of s.dependencies) {
resolveHierarchy(dep, visited);
}
}
};
console.log(`Resolving components with version ${version}:`);
resolveHierarchy(spec);
return resolved;
}
/**
* Log existing versions of components in registry (for debugging)
*/
logExistingVersions(spec, namespace = 'Global') {
const registry = this.adapter.getRegistry();
// Check for existing versions of this component
const namespaceComponents = registry.getNamespace(namespace);
const componentVersions = namespaceComponents.filter(c => c.name === spec.name);
// Log existing versions for awareness
if (componentVersions.length > 0) {
console.log(` Found ${componentVersions.length} existing version(s) of ${spec.name}:`);
componentVersions.forEach(comp => {
console.log(` - ${comp.name}@${comp.version}`);
});
}
// Recursively check dependencies
if (spec.dependencies) {
for (const dep of spec.dependencies) {
const depVersions = namespaceComponents.filter(c => c.name === dep.name);
if (depVersions.length > 0) {
console.log(` Found ${depVersions.length} existing version(s) of ${dep.name}:`);
depVersions.forEach(comp => {
console.log(` - ${comp.name}@${comp.version}`);
});
}
}
}
}
/**
* Register all components in the hierarchy
*/
async registerComponentHierarchy() {
// Generate unique version based on component code hash
const version = this.generateComponentHash(this.component);
this.componentVersion = version; // Store for use in resolver
console.log(`\n🔄 Registering component hierarchy for ${this.component.name}`);
console.log(` Version: ${version}`);
// Log existing versions (don't clear - allow multiple versions to coexist)
this.logExistingVersions(this.component);
// Check if this exact version already exists to avoid re-registration
const registry = this.adapter.getRegistry();
const existingComponent = registry.get(this.component.name, 'Global', version);
if (existingComponent) {
console.log(` ℹ️ Version ${version} already registered - skipping registration`);
return;
}
// Create the hierarchy registrar with adapter's compiler and registry
const registrar = new ComponentHierarchyRegistrar(this.adapter.getCompiler(), this.adapter.getRegistry(), this.adapter.getRuntimeContext());
await ComponentMetadataEngine.Instance.Config(false, Metadata.Provider.CurrentUser);
// Register the entire hierarchy with hash-based version
const result = await registrar.registerHierarchy(this.component, {
styles: this.styles, // Skip components use SkipComponentStyles which is a superset
namespace: 'Global',
version: version, // Use hash-based version instead of hardcoded 'v1'
allowOverride: false, // Don't override - each version is unique
allLibraries: ComponentMetadataEngine.Instance.ComponentLibraries
});
// Check for errors
if (!result.success) {
const errorMessages = result.errors.map(e => `${e.componentName}: ${e.error}`);
throw new Error(`Component registration failed: ${errorMessages.join(', ')}`);
}
// Log registered components for debugging
console.log(`✅ Successfully registered ${result.registeredComponents.length} components with version ${version}:`);
result.registeredComponents.forEach(name => {
console.log(` - ${name}@${version}`);
});
// Also log current registry stats
const stats = this.adapter.getRegistry().getStats();
console.log(`📊 Registry stats: ${stats.totalComponents} total components in ${stats.namespaces} namespace(s)\n`);
}
/**
* Render the React component
*/
renderComponent() {
// Don't render if component is being destroyed
if (this.isDestroying) {
return;
}
if (!this.compiledComponent || !this.reactRootId) {
return;
}
// Prevent concurrent renders
if (this.isRendering) {
this.pendingRender = true;
return;
}
const context = this.reactBridge.getCurrentContext();
if (!context) {
return;
}
this.isRendering = true;
const { React } = context;
// Manually resolve components with the correct version
const components = this.resolveComponentsWithVersion(this.component, this.componentVersion);
// Create callbacks once per component instance
if (!this.currentCallbacks) {
this.currentCallbacks = this.createCallbacks();
}
// Build props with savedUserSettings pattern
const props = {
utilities: this.utilities, // Now uses getter which auto-initializes if needed
callbacks: this.currentCallbacks,
components,
styles: this.styles,
savedUserSettings: this._savedUserSettings,
onSaveUserSettings: this.handleSaveUserSettings.bind(this)
};
// Validate component before creating element
if (!this.compiledComponent.component) {
LogError(`Component is undefined for ${this.component.name} during render`);
return;
}
// Create error boundary
const ErrorBoundary = createErrorBoundary(React, {
onError: this.handleReactError.bind(this),
logErrors: true,
recovery: 'retry'
});
// Create element with error boundary
const element = React.createElement(ErrorBoundary, null, React.createElement(this.compiledComponent.component, props));
// Render with timeout protection using resource manager
const timeoutId = resourceManager.setTimeout(this.componentId, () => {
// Check if still rendering and not destroyed
if (this.isRendering && !this.isDestroying) {
this.componentEvent.emit({
type: 'error',
payload: {
error: 'Component render timeout - possible infinite loop detected',
source: 'render'
}
});
}
}, 5000, { purpose: 'render-timeout-protection' });
// Use managed React root for rendering
reactRootManager.render(this.reactRootId, element, () => {
// Clear the timeout as render completed
resourceManager.clearTimeout(this.componentId, timeoutId);
// Don't update state if component is destroyed
if (this.isDestroying) {
return;
}
this.isRendering = false;
// If there was a pending render request, execute it now
if (this.pendingRender) {
this.pendingRender = false;
this.renderComponent();
}
});
}
/**
* Create callbacks for the React component
*/
createCallbacks() {
return {
OpenEntityRecord: async (entityName, key) => {
let keyToUse = null;
if (key instanceof Array) {
keyToUse = CompositeKey.FromKeyValuePairs(key);
}
else if (typeof key === 'object' && !!key.GetValueByFieldName) {
keyToUse = key;
}
else if (typeof key === 'object') {
//} && !!key.FieldName && !!key.Value) {
// possible that have an object that is a simple key/value pair with
// FieldName and value properties
const keyAny = key;
if (keyAny.FieldName && keyAny.Value) {
keyToUse = CompositeKey.FromKeyValuePairs([keyAny]);
}
}
if (keyToUse) {
// now in some cases we have key/value pairs that the component we are hosting
// use, but are not the pkey, so if that is the case, we'll run a quick view to try
// and get the pkey so that we can emit the openEntityRecord call with the pkey
const md = new Metadata();
const e = md.EntityByName(entityName);
let shouldRunView = false;
// now check each key in the keyToUse to see if it is a pkey
for (const singleKey of keyToUse.KeyValuePairs) {
const field = e.Fields.find(f => f.Name.trim().toLowerCase() === singleKey.FieldName.trim().toLowerCase());
if (!field) {
// if we get here this is a problem, the component has given us a non-matching field, this shouldn't ever happen
// but if it doesn't log warning to console and exit
console.warn(`Non-matching field found for key: ${JSON.stringify(keyToUse)}`);
return;
}
else if (!field.IsPrimaryKey) {
// if we get here that means we have a non-pkey so we'll want to do a lookup via a RunView
// to get the actual pkey value
shouldRunView = true;
break;
}
}
// if we get here and shouldRunView is true, we need to run a view using the info provided
// by our contained component to get the pkey
if (shouldRunView) {
const rv = new RunView();
const result = await rv.RunView({
EntityName: entityName,
ExtraFilter: keyToUse.ToWhereClause()
});
if (result && result.Success && result.Results.length > 0) {
// we have a match, use the first row and update our keyToUse
const kvPairs = [];
e.PrimaryKeys.forEach(pk => {
kvPairs.push({
FieldName: pk.Name,
Value: result.Results[0][pk.Name]
});
});
keyToUse = CompositeKey.FromKeyValuePairs(kvPairs);
}
}
this.openEntityRecord.emit({ entityName, key: keyToUse });
}
}
};
}
/**
* Handle React component errors
*/
handleReactError(error, errorInfo) {
LogError(`React component error: ${error?.toString() || 'Unknown error'}`, errorInfo);
this.componentEvent.emit({
type: 'error',
payload: {
error: error?.toString() || 'Unknown error',
errorInfo,
source: 'react'
}
});
}
/**
* Handle onSaveUserSettings from components
* This implements the SavedUserSettings pattern
*/
handleSaveUserSettings(newSettings) {
// Just bubble the event up to parent containers for persistence
// We don't need to store anything here
this.userSettingsChanged.emit({
settings: newSettings,
componentName: this.component?.name,
timestamp: new Date()
});
// DO NOT re-render the component!
// The component already has the correct state - it's the one that told us about the change.
// Re-rendering would cause unnecessary DOM updates and visual flashing.
}
/**
* Clean up resources
*/
cleanup() {
// Clean up all resources managed by resource manager
resourceManager.cleanupComponent(this.componentId);
// Clean up prop builder subscriptions
if (this.currentCallbacks) {
this.currentCallbacks = null;
}
// Unmount React root using managed unmount
if (this.reactRootId) {
// Force stop rendering flags
this.isRendering = false;
this.pendingRender = false;
// This will handle waiting for render completion if needed
reactRootManager.unmountRoot(this.reactRootId);
this.reactRootId = null;
}
// Clear references
this.compiledComponent = null;
this.isInitialized = false;
// Trigger registry cleanup
this.adapter.getRegistry().cleanup();
}
/**
* Public method to refresh the component
* @deprecated Components manage their own state and data now
*/
refresh() {
// Just trigger a re-render if needed
this.renderComponent();
}
/**
* Public method to update state programmatically
* @param path - State path to update
* @param value - New value
* @deprecated Components manage their own state now
*/
updateState(path, value) {
// Just emit the event, don't manage state here
this.stateChange.emit({ path, value });
}
static { this.ɵfac = function MJReactComponent_Factory(t) { return new (t || MJReactComponent)(i0.ɵɵdirectiveInject(i1.ReactBridgeService), i0.ɵɵdirectiveInject(i2.AngularAdapterService), i0.ɵɵdirectiveInject(i0.ChangeDetectorRef)); }; }
static { this.ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: MJReactComponent, selectors: [["mj-react-component"]], viewQuery: function MJReactComponent_Query(rf, ctx) { if (rf & 1) {
i0.ɵɵviewQuery(_c0, 7, ElementRef);
} if (rf & 2) {
let _t;
i0.ɵɵqueryRefresh(_t = i0.ɵɵloadQuery()) && (ctx.container = _t.first);
} }, inputs: { component: "component", utilities: "utilities", styles: "styles", savedUserSettings: "savedUserSettings" }, outputs: { stateChange: "stateChange", componentEvent: "componentEvent", refreshData: "refreshData", openEntityRecord: "openEntityRecord", userSettingsChanged: "userSettingsChanged" }, decls: 4, vars: 3, consts: [["container", ""], [1, "react-component-wrapper"], [1, "react-component-container"], [1, "loading-overlay"], [1, "loading-spinner"], [1, "fa-solid", "fa-spinner", "fa-spin"], [1, "loading-text"]], template: function MJReactComponent_Template(rf, ctx) { if (rf & 1) {
i0.ɵɵelementStart(0, "div", 1);
i0.ɵɵelement(1, "div", 2, 0);
i0.ɵɵtemplate(3, MJReactComponent_Conditional_3_Template, 5, 0, "div", 3);
i0.ɵɵelementEnd();
} if (rf & 2) {
i0.ɵɵadvance();
i0.ɵɵclassProp("loading", !ctx.isInitialized);
i0.ɵɵadvance(2);
i0.ɵɵconditional(!ctx.isInitialized && !ctx.hasError ? 3 : -1);
} }, styles: ["[_nghost-%COMP%] {\n display: block;\n width: 100%;\n height: 100%;\n }\n .react-component-wrapper[_ngcontent-%COMP%] {\n position: relative;\n width: 100%;\n height: 100%;\n }\n .react-component-container[_ngcontent-%COMP%] {\n width: 100%;\n height: 100%;\n transition: opacity 0.3s ease;\n }\n .react-component-container.loading[_ngcontent-%COMP%] {\n opacity: 0;\n }\n .loading-overlay[_ngcontent-%COMP%] {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n background-color: rgba(255, 255, 255, 0.9);\n z-index: 1;\n }\n .loading-spinner[_ngcontent-%COMP%] {\n font-size: 48px;\n color: #5B4FE9;\n margin-bottom: 16px;\n }\n .loading-text[_ngcontent-%COMP%] {\n font-family: -apple-system, BlinkMacSystemFont, \"Inter\", \"Segoe UI\", Roboto, sans-serif;\n font-size: 14px;\n color: #64748B;\n margin-top: 8px;\n }"], changeDetection: 0 }); }
}
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(MJReactComponent, [{
type: Component,
args: [{ selector: 'mj-react-component', template: `
<div class="react-component-wrapper">
<div #container class="react-component-container" [class.loading]="!isInitialized"></div>
(!isInitialized && !hasError) {
<div class="loading-overlay">
<div class="loading-spinner">
<i class="fa-solid fa-spinner fa-spin"></i>
</div>
<div class="loading-text">Loading component...</div>
</div>
}
</div>
`, changeDetection: ChangeDetectionStrategy.OnPush, styles: ["\n :host {\n display: block;\n width: 100%;\n height: 100%;\n }\n .react-component-wrapper {\n position: relative;\n width: 100%;\n height: 100%;\n }\n .react-component-container {\n width: 100%;\n height: 100%;\n transition: opacity 0.3s ease;\n }\n .react-component-container.loading {\n opacity: 0;\n }\n .loading-overlay {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n background-color: rgba(255, 255, 255, 0.9);\n z-index: 1;\n }\n .loading-spinner {\n font-size: 48px;\n color: #5B4FE9;\n margin-bottom: 16px;\n }\n .loading-text {\n font-family: -apple-system, BlinkMacSystemFont, \"Inter\", \"Segoe UI\", Roboto, sans-serif;\n font-size: 14px;\n color: #64748B;\n margin-top: 8px;\n }\n "] }]
}], () => [{ type: i1.ReactBridgeService }, { type: i2.AngularAdapterService }, { type: i0.ChangeDetectorRef }], { component: [{
type: Input
}], utilities: [{
type: Input
}], styles: [{
type: Input
}], savedUserSettings: [{
type: Input
}], stateChange: [{
type: Output
}], componentEvent: [{
type: Output
}], refreshData: [{
type: Output
}], openEntityRecord: [{
type: Output
}], userSettingsChanged: [{
type: Output
}], container: [{
type: ViewChild,
args: ['container', { read: ElementRef, static: true }]
}] }); })();
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(MJReactComponent, { className: "MJReactComponent", filePath: "lib/components/mj-react-component.component.ts", lineNumber: 128 }); })();
//# sourceMappingURL=mj-react-component.component.js.map