@memberjunction/react-runtime 2.75.0 → 2.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +23 -0
  3. package/dist/compiler/component-compiler.d.ts +0 -1
  4. package/dist/compiler/component-compiler.d.ts.map +1 -1
  5. package/dist/compiler/component-compiler.js +34 -25
  6. package/dist/index.d.ts +4 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +10 -2
  9. package/dist/registry/component-registry.d.ts +1 -0
  10. package/dist/registry/component-registry.d.ts.map +1 -1
  11. package/dist/registry/component-registry.js +6 -3
  12. package/dist/runtime/index.d.ts +2 -1
  13. package/dist/runtime/index.d.ts.map +1 -1
  14. package/dist/runtime/index.js +4 -2
  15. package/dist/runtime/prop-builder.d.ts +1 -2
  16. package/dist/runtime/prop-builder.d.ts.map +1 -1
  17. package/dist/runtime/prop-builder.js +4 -76
  18. package/dist/runtime/react-root-manager.d.ts +26 -0
  19. package/dist/runtime/react-root-manager.d.ts.map +1 -0
  20. package/dist/runtime/react-root-manager.js +122 -0
  21. package/dist/types/index.d.ts +1 -0
  22. package/dist/types/index.d.ts.map +1 -1
  23. package/dist/utilities/cache-manager.d.ts +38 -0
  24. package/dist/utilities/cache-manager.d.ts.map +1 -0
  25. package/dist/utilities/cache-manager.js +156 -0
  26. package/dist/utilities/core-libraries.d.ts +5 -0
  27. package/dist/utilities/core-libraries.d.ts.map +1 -0
  28. package/dist/utilities/core-libraries.js +52 -0
  29. package/dist/utilities/index.d.ts +2 -0
  30. package/dist/utilities/index.d.ts.map +1 -1
  31. package/dist/utilities/index.js +2 -0
  32. package/dist/utilities/library-loader.d.ts +2 -2
  33. package/dist/utilities/library-loader.d.ts.map +1 -1
  34. package/dist/utilities/library-loader.js +52 -24
  35. package/dist/utilities/resource-manager.d.ts +34 -0
  36. package/dist/utilities/resource-manager.d.ts.map +1 -0
  37. package/dist/utilities/resource-manager.js +174 -0
  38. package/package.json +4 -4
  39. package/samples/entities-1.js +493 -0
  40. package/src/compiler/component-compiler.ts +64 -35
  41. package/src/index.ts +18 -1
  42. package/src/registry/component-registry.ts +14 -4
  43. package/src/runtime/index.ts +7 -2
  44. package/src/runtime/prop-builder.ts +5 -112
  45. package/src/runtime/react-root-manager.ts +218 -0
  46. package/src/types/index.ts +2 -0
  47. package/src/utilities/cache-manager.ts +253 -0
  48. package/src/utilities/core-libraries.ts +61 -0
  49. package/src/utilities/index.ts +3 -1
  50. package/src/utilities/library-loader.ts +111 -47
  51. package/src/utilities/resource-manager.ts +305 -0
  52. package/tsconfig.tsbuildinfo +1 -1
@@ -161,7 +161,11 @@ export class ComponentCompiler {
161
161
  */
162
162
  private wrapComponentCode(componentCode: string, componentName: string): string {
163
163
  return `
164
- function createComponent(React, ReactDOM, useState, useEffect, useCallback, createStateUpdater, libraries, styles, console) {
164
+ function createComponent(
165
+ React, ReactDOM,
166
+ useState, useEffect, useCallback, useMemo, useRef, useContext, useReducer, useLayoutEffect,
167
+ libraries, styles, console
168
+ ) {
165
169
  ${componentCode}
166
170
 
167
171
  // Ensure the component exists
@@ -193,19 +197,17 @@ export class ComponentCompiler {
193
197
  */
194
198
  private createComponentFactory(transpiledCode: string, componentName: string): Function {
195
199
  try {
196
- // Create the factory function
200
+ // Create the factory function with all React hooks
197
201
  const factoryCreator = new Function(
198
- 'React', 'ReactDOM', 'useState', 'useEffect', 'useCallback',
199
- 'createStateUpdater', 'libraries', 'styles', 'console',
202
+ 'React', 'ReactDOM',
203
+ 'useState', 'useEffect', 'useCallback', 'useMemo', 'useRef', 'useContext', 'useReducer', 'useLayoutEffect',
204
+ 'libraries', 'styles', 'console',
200
205
  `${transpiledCode}; return createComponent;`
201
206
  );
202
207
 
203
208
  // Return a function that executes the factory with runtime context
204
209
  return (context: RuntimeContext, styles: any = {}) => {
205
210
  const { React, ReactDOM, libraries = {} } = context;
206
-
207
- // Create state updater utility
208
- const createStateUpdater = this.createStateUpdaterUtility();
209
211
 
210
212
  // Execute the factory creator to get the createComponent function
211
213
  const createComponentFn = factoryCreator(
@@ -214,7 +216,11 @@ export class ComponentCompiler {
214
216
  React.useState,
215
217
  React.useEffect,
216
218
  React.useCallback,
217
- createStateUpdater,
219
+ React.useMemo,
220
+ React.useRef,
221
+ React.useContext,
222
+ React.useReducer,
223
+ React.useLayoutEffect,
218
224
  libraries,
219
225
  styles,
220
226
  console
@@ -227,7 +233,11 @@ export class ComponentCompiler {
227
233
  React.useState,
228
234
  React.useEffect,
229
235
  React.useCallback,
230
- createStateUpdater,
236
+ React.useMemo,
237
+ React.useRef,
238
+ React.useContext,
239
+ React.useReducer,
240
+ React.useLayoutEffect,
231
241
  libraries,
232
242
  styles,
233
243
  console
@@ -238,28 +248,6 @@ export class ComponentCompiler {
238
248
  }
239
249
  }
240
250
 
241
- /**
242
- * Creates the state updater utility function for nested components
243
- * @returns State updater function
244
- */
245
- private createStateUpdaterUtility(): Function {
246
- return (statePath: string, parentStateUpdater: Function) => {
247
- return (componentStateUpdate: any) => {
248
- if (!statePath) {
249
- // Root component - pass through directly
250
- parentStateUpdater(componentStateUpdate);
251
- } else {
252
- // Sub-component - bubble up with path context
253
- const pathParts = statePath.split('.');
254
- const componentKey = pathParts[pathParts.length - 1];
255
-
256
- parentStateUpdater({
257
- [componentKey]: componentStateUpdate
258
- });
259
- }
260
- };
261
- };
262
- }
263
251
 
264
252
  /**
265
253
  * Validates compilation options
@@ -267,21 +255,62 @@ export class ComponentCompiler {
267
255
  * @throws Error if validation fails
268
256
  */
269
257
  private validateCompileOptions(options: CompileOptions): void {
258
+ // Check if options object exists
259
+ if (!options) {
260
+ throw new Error(
261
+ 'Component compilation failed: No options provided.\n' +
262
+ 'Expected an object with componentName and componentCode properties.\n' +
263
+ 'Example: { componentName: "MyComponent", componentCode: "function MyComponent() { ... }" }'
264
+ );
265
+ }
266
+
267
+ // Check component name
270
268
  if (!options.componentName) {
271
- throw new Error('Component name is required');
269
+ const providedKeys = Object.keys(options).join(', ');
270
+ throw new Error(
271
+ 'Component compilation failed: Component name is required.\n' +
272
+ `Received options with keys: [${providedKeys}]\n` +
273
+ 'Please ensure your component spec includes a "name" property.\n' +
274
+ 'Example: { name: "MyComponent", code: "..." }'
275
+ );
272
276
  }
273
277
 
278
+ // Check component code
274
279
  if (!options.componentCode) {
275
- throw new Error('Component code is required');
280
+ throw new Error(
281
+ `Component compilation failed: Component code is required for "${options.componentName}".\n` +
282
+ 'Please ensure your component spec includes a "code" property with the component source code.\n' +
283
+ 'Example: { name: "MyComponent", code: "function MyComponent() { return <div>Hello</div>; }" }'
284
+ );
276
285
  }
277
286
 
287
+ // Check code type
278
288
  if (typeof options.componentCode !== 'string') {
279
- throw new Error('Component code must be a string');
289
+ const actualType = typeof options.componentCode;
290
+ throw new Error(
291
+ `Component compilation failed: Component code must be a string for "${options.componentName}".\n` +
292
+ `Received type: ${actualType}\n` +
293
+ `Received value: ${JSON.stringify(options.componentCode).substring(0, 100)}...\n` +
294
+ 'Please ensure the code property contains a string of JavaScript/JSX code.'
295
+ );
296
+ }
297
+
298
+ // Check if code is empty or whitespace only
299
+ if (options.componentCode.trim().length === 0) {
300
+ throw new Error(
301
+ `Component compilation failed: Component code is empty for "${options.componentName}".\n` +
302
+ 'The code property must contain valid JavaScript/JSX code defining a React component.'
303
+ );
280
304
  }
281
305
 
282
306
  // Basic syntax check
283
307
  if (!options.componentCode.includes(options.componentName)) {
284
- throw new Error(`Component code must define a component named "${options.componentName}"`);
308
+ throw new Error(
309
+ `Component compilation failed: Component code must define a component named "${options.componentName}".\n` +
310
+ 'The function/component name in the code must match the componentName property.\n' +
311
+ `Expected to find: function ${options.componentName}(...) or const ${options.componentName} = ...\n` +
312
+ 'Code preview: ' + options.componentCode.substring(0, 200) + '...'
313
+ );
285
314
  }
286
315
  }
287
316
 
package/src/index.ts CHANGED
@@ -53,7 +53,6 @@ export {
53
53
 
54
54
  export {
55
55
  buildComponentProps,
56
- cleanupPropBuilder,
57
56
  normalizeCallbacks,
58
57
  normalizeStyles,
59
58
  validateComponentProps,
@@ -75,6 +74,12 @@ export {
75
74
  HierarchyRegistrationOptions
76
75
  } from './runtime';
77
76
 
77
+ export {
78
+ ReactRootManager,
79
+ reactRootManager,
80
+ ManagedReactRoot
81
+ } from './runtime';
82
+
78
83
  // Export utilities
79
84
  export {
80
85
  RuntimeUtilities,
@@ -103,6 +108,18 @@ export {
103
108
  FailedComponentInfo
104
109
  } from './utilities/component-error-analyzer';
105
110
 
111
+ export {
112
+ ResourceManager,
113
+ resourceManager,
114
+ ManagedResource
115
+ } from './utilities/resource-manager';
116
+
117
+ export {
118
+ CacheManager,
119
+ CacheEntry,
120
+ CacheOptions
121
+ } from './utilities/cache-manager';
122
+
106
123
  // Version information
107
124
  export const VERSION = '2.69.1';
108
125
 
@@ -9,6 +9,7 @@ import {
9
9
  ComponentMetadata,
10
10
  RegistryConfig
11
11
  } from '../types';
12
+ import { resourceManager } from '../utilities/resource-manager';
12
13
 
13
14
  /**
14
15
  * Default registry configuration
@@ -28,6 +29,7 @@ export class ComponentRegistry {
28
29
  private registry: Map<string, RegistryEntry>;
29
30
  private config: RegistryConfig;
30
31
  private cleanupTimer?: NodeJS.Timeout | number;
32
+ private registryId: string;
31
33
 
32
34
  /**
33
35
  * Creates a new ComponentRegistry instance
@@ -36,6 +38,7 @@ export class ComponentRegistry {
36
38
  constructor(config?: Partial<RegistryConfig>) {
37
39
  this.config = { ...DEFAULT_REGISTRY_CONFIG, ...config };
38
40
  this.registry = new Map();
41
+ this.registryId = `component-registry-${Date.now()}`;
39
42
 
40
43
  // Start cleanup timer if configured
41
44
  if (this.config.cleanupInterval > 0) {
@@ -297,6 +300,8 @@ export class ComponentRegistry {
297
300
  destroy(): void {
298
301
  this.stopCleanupTimer();
299
302
  this.clear();
303
+ // Clean up any resources associated with this registry
304
+ resourceManager.cleanupComponent(this.registryId);
300
305
  }
301
306
 
302
307
  /**
@@ -362,9 +367,14 @@ export class ComponentRegistry {
362
367
  * Starts the automatic cleanup timer
363
368
  */
364
369
  private startCleanupTimer(): void {
365
- this.cleanupTimer = setInterval(() => {
366
- this.cleanup();
367
- }, this.config.cleanupInterval);
370
+ this.cleanupTimer = resourceManager.setInterval(
371
+ this.registryId,
372
+ () => {
373
+ this.cleanup();
374
+ },
375
+ this.config.cleanupInterval,
376
+ { purpose: 'component-registry-cleanup' }
377
+ );
368
378
  }
369
379
 
370
380
  /**
@@ -372,7 +382,7 @@ export class ComponentRegistry {
372
382
  */
373
383
  private stopCleanupTimer(): void {
374
384
  if (this.cleanupTimer) {
375
- clearInterval(this.cleanupTimer as any);
385
+ resourceManager.clearInterval(this.registryId, this.cleanupTimer as number);
376
386
  this.cleanupTimer = undefined;
377
387
  }
378
388
  }
@@ -23,7 +23,6 @@ export {
23
23
 
24
24
  export {
25
25
  buildComponentProps,
26
- cleanupPropBuilder,
27
26
  normalizeCallbacks,
28
27
  normalizeStyles,
29
28
  validateComponentProps,
@@ -43,4 +42,10 @@ export {
43
42
  HierarchyRegistrationResult,
44
43
  ComponentRegistrationError,
45
44
  HierarchyRegistrationOptions
46
- } from './component-hierarchy';
45
+ } from './component-hierarchy';
46
+
47
+ export {
48
+ ReactRootManager,
49
+ reactRootManager,
50
+ ManagedReactRoot
51
+ } from './react-root-manager';
@@ -41,7 +41,8 @@ export function buildComponentProps(
41
41
  callbacks: ComponentCallbacks = {},
42
42
  components: Record<string, any> = {},
43
43
  styles?: ComponentStyles,
44
- options: PropBuilderOptions = {}
44
+ options: PropBuilderOptions = {},
45
+ onStateChanged?: (stateUpdate: Record<string, any>) => void
45
46
  ): ComponentProps {
46
47
  const {
47
48
  validate = true,
@@ -61,7 +62,8 @@ export function buildComponentProps(
61
62
  utilities,
62
63
  callbacks: normalizeCallbacks(callbacks, debounceUpdateUserState),
63
64
  components,
64
- styles: normalizeStyles(styles)
65
+ styles: normalizeStyles(styles),
66
+ onStateChanged
65
67
  };
66
68
 
67
69
  // Validate if enabled
@@ -124,74 +126,6 @@ export function normalizeCallbacks(callbacks: any, debounceMs: number = 3000): C
124
126
  normalized.OpenEntityRecord = callbacks.OpenEntityRecord;
125
127
  }
126
128
 
127
- if (callbacks.UpdateUserState && typeof callbacks.UpdateUserState === 'function') {
128
- // Create a debounced version of UpdateUserState with loop detection
129
- const originalCallback = callbacks.UpdateUserState;
130
-
131
- // Get or create a subject for this callback
132
- let subject = updateUserStateSubjects.get(originalCallback);
133
- if (!subject) {
134
- subject = new Subject<any>();
135
- updateUserStateSubjects.set(originalCallback, subject);
136
-
137
- // Subscribe to the subject with debounce
138
- const subscription = subject.pipe(
139
- debounceTime(debounceMs)
140
- ).subscribe(state => {
141
- console.log(`[Skip Component] UpdateUserState called after ${debounceMs}ms debounce`);
142
- originalCallback(state);
143
- });
144
-
145
- // Store the subscription for cleanup
146
- updateUserStateSubscriptions.set(originalCallback, subscription);
147
- }
148
-
149
- // Get or create loop detection state
150
- let loopState = loopDetectionStates.get(originalCallback);
151
- if (!loopState) {
152
- loopState = { count: 0, lastUpdate: 0, lastState: null };
153
- loopDetectionStates.set(originalCallback, loopState);
154
- }
155
-
156
- // Return a function that prevents redundant updates
157
- normalized.UpdateUserState = (state: any) => {
158
- // Check if this is a redundant update
159
- if (loopState!.lastState && deepEqual(state, loopState!.lastState)) {
160
- console.log('[Skip Component] Skipping redundant state update');
161
- return; // Don't process identical state updates
162
- }
163
-
164
- const now = Date.now();
165
- const timeSinceLastUpdate = now - loopState!.lastUpdate;
166
-
167
- // Check for rapid updates
168
- if (timeSinceLastUpdate < 100) {
169
- loopState!.count++;
170
-
171
- if (loopState!.count > 5) {
172
- console.error('[Skip Component] Rapid state updates detected - possible infinite loop');
173
- console.error('Updates in last 100ms:', loopState!.count);
174
- // Still process the update but warn
175
- }
176
- } else {
177
- // Reset counter if more than 100ms has passed
178
- loopState!.count = 0;
179
- }
180
-
181
- loopState!.lastUpdate = now;
182
- loopState!.lastState = JSON.parse(JSON.stringify(state)); // Deep clone to preserve state
183
-
184
- console.log('[Skip Component] Processing state update');
185
-
186
- // Push to debounce subject (which already has 3 second debounce)
187
- subject!.next(state);
188
- };
189
- }
190
-
191
- if (callbacks.NotifyEvent && typeof callbacks.NotifyEvent === 'function') {
192
- normalized.NotifyEvent = callbacks.NotifyEvent;
193
- }
194
-
195
129
  return normalized;
196
130
  }
197
131
 
@@ -284,34 +218,7 @@ export function mergeProps(...propsList: Partial<ComponentProps>[]): ComponentPr
284
218
 
285
219
  return merged;
286
220
  }
287
-
288
- /**
289
- * Cleanup function for prop builder resources
290
- * @param callbacks - The callbacks object that was used to build props
291
- */
292
- export function cleanupPropBuilder(callbacks: ComponentCallbacks): void {
293
- if (callbacks.UpdateUserState && typeof callbacks.UpdateUserState === 'function') {
294
- const originalCallback = callbacks.UpdateUserState;
295
-
296
- // Unsubscribe from the subject
297
- const subscription = updateUserStateSubscriptions.get(originalCallback);
298
- if (subscription) {
299
- subscription.unsubscribe();
300
- updateUserStateSubscriptions.delete(originalCallback);
301
- }
302
-
303
- // Complete and remove the subject
304
- const subject = updateUserStateSubjects.get(originalCallback);
305
- if (subject) {
306
- subject.complete();
307
- updateUserStateSubjects.delete(originalCallback);
308
- }
309
-
310
- // Clear loop detection state
311
- loopDetectionStates.delete(originalCallback);
312
- }
313
- }
314
-
221
+
315
222
  /**
316
223
  * Creates a props transformer function
317
224
  * @param transformations - Map of prop paths to transformer functions
@@ -372,20 +279,6 @@ export function wrapCallbacksWithLogging(
372
279
  };
373
280
  }
374
281
 
375
- if (callbacks.UpdateUserState) {
376
- wrapped.UpdateUserState = (state: any) => {
377
- console.log(`[${componentName}] UpdateUserState called:`, state);
378
- callbacks.UpdateUserState!(state);
379
- };
380
- }
381
-
382
- if (callbacks.NotifyEvent) {
383
- wrapped.NotifyEvent = (event: string, data: any) => {
384
- console.log(`[${componentName}] NotifyEvent called:`, { event, data });
385
- callbacks.NotifyEvent!(event, data);
386
- };
387
- }
388
-
389
282
  return wrapped;
390
283
  }
391
284
 
@@ -0,0 +1,218 @@
1
+ /**
2
+ * @fileoverview React root lifecycle management for preventing memory leaks
3
+ * @module @memberjunction/react-runtime/runtime
4
+ */
5
+
6
+ import { resourceManager } from '../utilities/resource-manager';
7
+
8
+ export interface ManagedReactRoot {
9
+ id: string;
10
+ root: any; // React root instance
11
+ container: HTMLElement;
12
+ isRendering: boolean;
13
+ lastRenderTime?: Date;
14
+ componentId?: string;
15
+ }
16
+
17
+ /**
18
+ * Manages React root instances to prevent memory leaks and ensure proper cleanup.
19
+ * Handles safe rendering and unmounting with protection against concurrent operations.
20
+ */
21
+ export class ReactRootManager {
22
+ private roots = new Map<string, ManagedReactRoot>();
23
+ private renderingRoots = new Set<string>();
24
+ private unmountQueue = new Map<string, () => void>();
25
+
26
+ /**
27
+ * Create a new managed React root
28
+ * @param container - The DOM container element
29
+ * @param createRootFn - Function to create the React root (e.g., ReactDOM.createRoot)
30
+ * @param componentId - Optional component ID for resource tracking
31
+ * @returns The root ID for future operations
32
+ */
33
+ createRoot(
34
+ container: HTMLElement,
35
+ createRootFn: (container: HTMLElement) => any,
36
+ componentId?: string
37
+ ): string {
38
+ const rootId = `react-root-${Date.now()}-${Math.random()}`;
39
+ const root = createRootFn(container);
40
+
41
+ const managedRoot: ManagedReactRoot = {
42
+ id: rootId,
43
+ root,
44
+ container,
45
+ isRendering: false,
46
+ componentId
47
+ };
48
+
49
+ this.roots.set(rootId, managedRoot);
50
+
51
+ // Register with resource manager if component ID provided
52
+ if (componentId) {
53
+ resourceManager.registerReactRoot(
54
+ componentId,
55
+ root,
56
+ () => this.unmountRoot(rootId)
57
+ );
58
+ }
59
+
60
+ return rootId;
61
+ }
62
+
63
+ /**
64
+ * Safely render content to a React root
65
+ * @param rootId - The root ID
66
+ * @param element - React element to render
67
+ * @param onComplete - Optional callback when render completes
68
+ */
69
+ render(
70
+ rootId: string,
71
+ element: any,
72
+ onComplete?: () => void
73
+ ): void {
74
+ const managedRoot = this.roots.get(rootId);
75
+ if (!managedRoot) {
76
+ console.warn(`React root ${rootId} not found`);
77
+ return;
78
+ }
79
+
80
+ // Don't render if we're already unmounting
81
+ if (this.unmountQueue.has(rootId)) {
82
+ console.warn(`React root ${rootId} is being unmounted, skipping render`);
83
+ return;
84
+ }
85
+
86
+ // Mark as rendering
87
+ managedRoot.isRendering = true;
88
+ this.renderingRoots.add(rootId);
89
+
90
+ try {
91
+ managedRoot.root.render(element);
92
+ managedRoot.lastRenderTime = new Date();
93
+
94
+ // Use microtask to ensure React has completed its work
95
+ Promise.resolve().then(() => {
96
+ managedRoot.isRendering = false;
97
+ this.renderingRoots.delete(rootId);
98
+
99
+ // Process any pending unmount
100
+ const pendingUnmount = this.unmountQueue.get(rootId);
101
+ if (pendingUnmount) {
102
+ this.unmountQueue.delete(rootId);
103
+ pendingUnmount();
104
+ }
105
+
106
+ if (onComplete) {
107
+ onComplete();
108
+ }
109
+ });
110
+ } catch (error) {
111
+ // Clean up rendering state on error
112
+ managedRoot.isRendering = false;
113
+ this.renderingRoots.delete(rootId);
114
+ throw error;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Safely unmount a React root
120
+ * @param rootId - The root ID
121
+ * @param force - Force unmount even if rendering
122
+ */
123
+ unmountRoot(rootId: string, force: boolean = false): Promise<void> {
124
+ return new Promise((resolve) => {
125
+ const managedRoot = this.roots.get(rootId);
126
+ if (!managedRoot) {
127
+ resolve();
128
+ return;
129
+ }
130
+
131
+ const performUnmount = () => {
132
+ try {
133
+ managedRoot.root.unmount();
134
+ this.roots.delete(rootId);
135
+ this.renderingRoots.delete(rootId);
136
+
137
+ // Clean up container to ensure no dangling references
138
+ if (managedRoot.container) {
139
+ // Clear React's internal references
140
+ delete (managedRoot.container as any)._reactRootContainer;
141
+ // Clear all event listeners
142
+ const newContainer = managedRoot.container.cloneNode(false) as HTMLElement;
143
+ managedRoot.container.parentNode?.replaceChild(newContainer, managedRoot.container);
144
+ }
145
+
146
+ resolve();
147
+ } catch (error) {
148
+ console.error(`Error unmounting React root ${rootId}:`, error);
149
+ // Still clean up our tracking even if unmount failed
150
+ this.roots.delete(rootId);
151
+ this.renderingRoots.delete(rootId);
152
+ resolve();
153
+ }
154
+ };
155
+
156
+ // If not rendering or force unmount, unmount immediately
157
+ if (!managedRoot.isRendering || force) {
158
+ performUnmount();
159
+ } else {
160
+ // Queue unmount for after rendering completes
161
+ this.unmountQueue.set(rootId, () => {
162
+ performUnmount();
163
+ });
164
+ }
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Unmount all roots associated with a component
170
+ * @param componentId - The component ID
171
+ */
172
+ async unmountComponentRoots(componentId: string): Promise<void> {
173
+ const rootIds: string[] = [];
174
+
175
+ for (const [rootId, managedRoot] of this.roots) {
176
+ if (managedRoot.componentId === componentId) {
177
+ rootIds.push(rootId);
178
+ }
179
+ }
180
+
181
+ await Promise.all(rootIds.map(id => this.unmountRoot(id)));
182
+ }
183
+
184
+ /**
185
+ * Check if a root is currently rendering
186
+ * @param rootId - The root ID
187
+ * @returns true if rendering
188
+ */
189
+ isRendering(rootId: string): boolean {
190
+ return this.renderingRoots.has(rootId);
191
+ }
192
+
193
+ /**
194
+ * Get statistics about managed roots
195
+ */
196
+ getStats(): {
197
+ totalRoots: number;
198
+ renderingRoots: number;
199
+ pendingUnmounts: number;
200
+ } {
201
+ return {
202
+ totalRoots: this.roots.size,
203
+ renderingRoots: this.renderingRoots.size,
204
+ pendingUnmounts: this.unmountQueue.size
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Clean up all roots (for testing or shutdown)
210
+ */
211
+ async cleanup(): Promise<void> {
212
+ const allRootIds = Array.from(this.roots.keys());
213
+ await Promise.all(allRootIds.map(id => this.unmountRoot(id, true)));
214
+ }
215
+ }
216
+
217
+ // Singleton instance
218
+ export const reactRootManager = new ReactRootManager();
@@ -114,6 +114,8 @@ export interface ComponentProps {
114
114
  components?: Record<string, any>;
115
115
  /** Component styles */
116
116
  styles?: ComponentStyles;
117
+ /** Standard state change handler for controlled components */
118
+ onStateChanged?: (stateUpdate: Record<string, any>) => void;
117
119
  }
118
120
 
119
121
  /**