@memberjunction/react-runtime 2.76.0 → 2.78.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 (35) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +18 -0
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +10 -1
  6. package/dist/registry/component-registry.d.ts +1 -0
  7. package/dist/registry/component-registry.d.ts.map +1 -1
  8. package/dist/registry/component-registry.js +6 -3
  9. package/dist/runtime/index.d.ts +1 -0
  10. package/dist/runtime/index.d.ts.map +1 -1
  11. package/dist/runtime/index.js +4 -1
  12. package/dist/runtime/react-root-manager.d.ts +26 -0
  13. package/dist/runtime/react-root-manager.d.ts.map +1 -0
  14. package/dist/runtime/react-root-manager.js +122 -0
  15. package/dist/utilities/cache-manager.d.ts +38 -0
  16. package/dist/utilities/cache-manager.d.ts.map +1 -0
  17. package/dist/utilities/cache-manager.js +156 -0
  18. package/dist/utilities/index.d.ts +2 -0
  19. package/dist/utilities/index.d.ts.map +1 -1
  20. package/dist/utilities/index.js +2 -0
  21. package/dist/utilities/library-loader.d.ts.map +1 -1
  22. package/dist/utilities/library-loader.js +25 -8
  23. package/dist/utilities/resource-manager.d.ts +36 -0
  24. package/dist/utilities/resource-manager.d.ts.map +1 -0
  25. package/dist/utilities/resource-manager.js +225 -0
  26. package/package.json +4 -4
  27. package/src/index.ts +18 -0
  28. package/src/registry/component-registry.ts +14 -4
  29. package/src/runtime/index.ts +7 -1
  30. package/src/runtime/react-root-manager.ts +218 -0
  31. package/src/utilities/cache-manager.ts +253 -0
  32. package/src/utilities/index.ts +3 -1
  33. package/src/utilities/library-loader.ts +66 -21
  34. package/src/utilities/resource-manager.ts +371 -0
  35. package/tsconfig.tsbuildinfo +1 -1
@@ -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();
@@ -0,0 +1,253 @@
1
+ /**
2
+ * @fileoverview Cache management with TTL and size limits
3
+ * @module @memberjunction/react-runtime/utilities
4
+ */
5
+
6
+ export interface CacheEntry<T> {
7
+ value: T;
8
+ timestamp: number;
9
+ size?: number;
10
+ }
11
+
12
+ export interface CacheOptions {
13
+ maxSize?: number; // Maximum number of entries
14
+ maxMemory?: number; // Maximum memory in bytes (estimated)
15
+ defaultTTL?: number; // Default TTL in milliseconds
16
+ cleanupInterval?: number; // Cleanup interval in milliseconds
17
+ }
18
+
19
+ /**
20
+ * Cache manager with TTL and size limits.
21
+ * Provides automatic cleanup and memory management.
22
+ */
23
+ export class CacheManager<T = any> {
24
+ private cache = new Map<string, CacheEntry<T>>();
25
+ private memoryUsage = 0;
26
+ private cleanupTimer?: number;
27
+ private readonly options: Required<CacheOptions>;
28
+
29
+ constructor(options: CacheOptions = {}) {
30
+ this.options = {
31
+ maxSize: options.maxSize || 1000,
32
+ maxMemory: options.maxMemory || 50 * 1024 * 1024, // 50MB default
33
+ defaultTTL: options.defaultTTL || 5 * 60 * 1000, // 5 minutes default
34
+ cleanupInterval: options.cleanupInterval || 60 * 1000 // 1 minute default
35
+ };
36
+
37
+ if (this.options.cleanupInterval > 0) {
38
+ this.startCleanupTimer();
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Set a value in the cache
44
+ */
45
+ set(key: string, value: T, ttl?: number): void {
46
+ const size = this.estimateSize(value);
47
+ const entry: CacheEntry<T> = {
48
+ value,
49
+ timestamp: Date.now(),
50
+ size
51
+ };
52
+
53
+ // Check if we need to evict entries
54
+ if (this.cache.size >= this.options.maxSize) {
55
+ this.evictLRU();
56
+ }
57
+
58
+ // Check memory usage
59
+ if (this.memoryUsage + size > this.options.maxMemory) {
60
+ this.evictByMemory(size);
61
+ }
62
+
63
+ // Remove old entry if exists
64
+ const oldEntry = this.cache.get(key);
65
+ if (oldEntry) {
66
+ this.memoryUsage -= oldEntry.size || 0;
67
+ }
68
+
69
+ this.cache.set(key, entry);
70
+ this.memoryUsage += size;
71
+
72
+ // Schedule removal if TTL is set
73
+ if (ttl || this.options.defaultTTL) {
74
+ const timeout = ttl || this.options.defaultTTL;
75
+ setTimeout(() => this.delete(key), timeout);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Get a value from the cache
81
+ */
82
+ get(key: string): T | undefined {
83
+ const entry = this.cache.get(key);
84
+ if (!entry) return undefined;
85
+
86
+ // Check if expired
87
+ if (this.isExpired(entry)) {
88
+ this.delete(key);
89
+ return undefined;
90
+ }
91
+
92
+ // Update timestamp for LRU
93
+ entry.timestamp = Date.now();
94
+ return entry.value;
95
+ }
96
+
97
+ /**
98
+ * Check if a key exists and is not expired
99
+ */
100
+ has(key: string): boolean {
101
+ const entry = this.cache.get(key);
102
+ if (!entry) return false;
103
+
104
+ if (this.isExpired(entry)) {
105
+ this.delete(key);
106
+ return false;
107
+ }
108
+
109
+ return true;
110
+ }
111
+
112
+ /**
113
+ * Delete a key from the cache
114
+ */
115
+ delete(key: string): boolean {
116
+ const entry = this.cache.get(key);
117
+ if (entry) {
118
+ this.memoryUsage -= entry.size || 0;
119
+ return this.cache.delete(key);
120
+ }
121
+ return false;
122
+ }
123
+
124
+ /**
125
+ * Clear all entries
126
+ */
127
+ clear(): void {
128
+ this.cache.clear();
129
+ this.memoryUsage = 0;
130
+ }
131
+
132
+ /**
133
+ * Get cache statistics
134
+ */
135
+ getStats(): {
136
+ size: number;
137
+ memoryUsage: number;
138
+ maxSize: number;
139
+ maxMemory: number;
140
+ } {
141
+ return {
142
+ size: this.cache.size,
143
+ memoryUsage: this.memoryUsage,
144
+ maxSize: this.options.maxSize,
145
+ maxMemory: this.options.maxMemory
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Manually trigger cleanup
151
+ */
152
+ cleanup(): number {
153
+ let removed = 0;
154
+ const now = Date.now();
155
+
156
+ for (const [key, entry] of this.cache) {
157
+ if (this.isExpired(entry, now)) {
158
+ this.delete(key);
159
+ removed++;
160
+ }
161
+ }
162
+
163
+ return removed;
164
+ }
165
+
166
+ /**
167
+ * Destroy the cache and stop cleanup timer
168
+ */
169
+ destroy(): void {
170
+ this.stopCleanupTimer();
171
+ this.clear();
172
+ }
173
+
174
+ /**
175
+ * Check if an entry is expired
176
+ */
177
+ private isExpired(entry: CacheEntry<T>, now?: number): boolean {
178
+ if (!this.options.defaultTTL) return false;
179
+ const currentTime = now || Date.now();
180
+ return currentTime - entry.timestamp > this.options.defaultTTL;
181
+ }
182
+
183
+ /**
184
+ * Evict least recently used entry
185
+ */
186
+ private evictLRU(): void {
187
+ let lruKey: string | undefined;
188
+ let lruTime = Infinity;
189
+
190
+ for (const [key, entry] of this.cache) {
191
+ if (entry.timestamp < lruTime) {
192
+ lruTime = entry.timestamp;
193
+ lruKey = key;
194
+ }
195
+ }
196
+
197
+ if (lruKey) {
198
+ this.delete(lruKey);
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Evict entries to make room for new memory
204
+ */
205
+ private evictByMemory(requiredSize: number): void {
206
+ const entries = Array.from(this.cache.entries())
207
+ .sort((a, b) => a[1].timestamp - b[1].timestamp);
208
+
209
+ let freedMemory = 0;
210
+ for (const [key, entry] of entries) {
211
+ if (freedMemory >= requiredSize) break;
212
+ freedMemory += entry.size || 0;
213
+ this.delete(key);
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Estimate size of a value
219
+ */
220
+ private estimateSize(value: T): number {
221
+ if (typeof value === 'string') {
222
+ return value.length * 2; // 2 bytes per character
223
+ } else if (typeof value === 'object' && value !== null) {
224
+ // Rough estimation for objects
225
+ try {
226
+ return JSON.stringify(value).length * 2;
227
+ } catch {
228
+ return 1024; // Default 1KB for objects that can't be stringified
229
+ }
230
+ } else {
231
+ return 8; // Default for primitives
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Start the cleanup timer
237
+ */
238
+ private startCleanupTimer(): void {
239
+ this.cleanupTimer = window.setInterval(() => {
240
+ this.cleanup();
241
+ }, this.options.cleanupInterval);
242
+ }
243
+
244
+ /**
245
+ * Stop the cleanup timer
246
+ */
247
+ private stopCleanupTimer(): void {
248
+ if (this.cleanupTimer) {
249
+ window.clearInterval(this.cleanupTimer);
250
+ this.cleanupTimer = undefined;
251
+ }
252
+ }
253
+ }
@@ -7,4 +7,6 @@ export * from './runtime-utilities';
7
7
  export * from './component-styles';
8
8
  export * from './standard-libraries';
9
9
  export * from './library-loader';
10
- export * from './component-error-analyzer';
10
+ export * from './component-error-analyzer';
11
+ export * from './resource-manager';
12
+ export * from './cache-manager';
@@ -10,6 +10,10 @@ import {
10
10
  } from './standard-libraries';
11
11
  import { LibraryConfiguration, ExternalLibraryConfig, LibraryLoadOptions as ConfigLoadOptions } from '../types/library-config';
12
12
  import { getCoreRuntimeLibraries, isCoreRuntimeLibrary } from './core-libraries';
13
+ import { resourceManager } from './resource-manager';
14
+
15
+ // Unique component ID for resource tracking
16
+ const LIBRARY_LOADER_COMPONENT_ID = 'mj-react-runtime-library-loader-singleton';
13
17
 
14
18
  /**
15
19
  * Represents a loaded script or CSS resource
@@ -214,28 +218,46 @@ export class LibraryLoader {
214
218
  script.async = true;
215
219
  script.crossOrigin = 'anonymous';
216
220
 
217
- script.onload = () => {
221
+ const cleanup = () => {
222
+ script.removeEventListener('load', onLoad);
223
+ script.removeEventListener('error', onError);
224
+ };
225
+
226
+ const onLoad = () => {
227
+ cleanup();
218
228
  const global = (window as any)[globalName];
219
229
  if (global) {
220
230
  resolve(global);
221
231
  } else {
222
232
  // Some libraries may take a moment to initialize
223
- setTimeout(() => {
224
- const delayedGlobal = (window as any)[globalName];
225
- if (delayedGlobal) {
226
- resolve(delayedGlobal);
227
- } else {
228
- reject(new Error(`${globalName} not found after script load`));
229
- }
230
- }, 100);
233
+ const timeoutId = resourceManager.setTimeout(
234
+ LIBRARY_LOADER_COMPONENT_ID,
235
+ () => {
236
+ const delayedGlobal = (window as any)[globalName];
237
+ if (delayedGlobal) {
238
+ resolve(delayedGlobal);
239
+ } else {
240
+ reject(new Error(`${globalName} not found after script load`));
241
+ }
242
+ },
243
+ 100,
244
+ { url, globalName }
245
+ );
231
246
  }
232
247
  };
233
248
 
234
- script.onerror = () => {
249
+ const onError = () => {
250
+ cleanup();
235
251
  reject(new Error(`Failed to load script: ${url}`));
236
252
  };
237
253
 
254
+ script.addEventListener('load', onLoad);
255
+ script.addEventListener('error', onError);
256
+
238
257
  document.head.appendChild(script);
258
+
259
+ // Register the script element for cleanup
260
+ resourceManager.registerDOMElement(LIBRARY_LOADER_COMPONENT_ID, script);
239
261
  });
240
262
 
241
263
  this.loadedResources.set(url, {
@@ -263,6 +285,9 @@ export class LibraryLoader {
263
285
  link.rel = 'stylesheet';
264
286
  link.href = url;
265
287
  document.head.appendChild(link);
288
+
289
+ // Register the link element for cleanup
290
+ resourceManager.registerDOMElement(LIBRARY_LOADER_COMPONENT_ID, link);
266
291
 
267
292
  this.loadedResources.set(url, {
268
293
  element: link,
@@ -285,14 +310,19 @@ export class LibraryLoader {
285
310
  resolve(global);
286
311
  } else {
287
312
  // Retry after a short delay
288
- setTimeout(() => {
289
- const delayedGlobal = (window as any)[globalName];
290
- if (delayedGlobal) {
291
- resolve(delayedGlobal);
292
- } else {
293
- reject(new Error(`${globalName} not found after script load`));
294
- }
295
- }, 100);
313
+ resourceManager.setTimeout(
314
+ LIBRARY_LOADER_COMPONENT_ID,
315
+ () => {
316
+ const delayedGlobal = (window as any)[globalName];
317
+ if (delayedGlobal) {
318
+ resolve(delayedGlobal);
319
+ } else {
320
+ reject(new Error(`${globalName} not found after script load`));
321
+ }
322
+ },
323
+ 100,
324
+ { context: 'waitForScriptLoad', globalName }
325
+ );
296
326
  }
297
327
  };
298
328
 
@@ -304,10 +334,15 @@ export class LibraryLoader {
304
334
 
305
335
  // Wait for load
306
336
  const loadHandler = () => {
307
- script.removeEventListener('load', loadHandler);
308
337
  checkGlobal();
309
338
  };
310
- script.addEventListener('load', loadHandler);
339
+ resourceManager.addEventListener(
340
+ LIBRARY_LOADER_COMPONENT_ID,
341
+ script,
342
+ 'load',
343
+ loadHandler,
344
+ { once: true }
345
+ );
311
346
  }
312
347
 
313
348
 
@@ -319,9 +354,19 @@ export class LibraryLoader {
319
354
  }
320
355
 
321
356
  /**
322
- * Clear loaded resources cache
357
+ * Clear loaded resources cache and cleanup DOM elements
323
358
  */
324
359
  static clearCache(): void {
360
+ // Remove all script and link elements we added
361
+ this.loadedResources.forEach((resource, url) => {
362
+ if (resource.element && resource.element.parentNode) {
363
+ resource.element.parentNode.removeChild(resource.element);
364
+ }
365
+ });
366
+
325
367
  this.loadedResources.clear();
368
+
369
+ // Clean up any resources managed by resource manager
370
+ resourceManager.cleanupComponent(LIBRARY_LOADER_COMPONENT_ID);
326
371
  }
327
372
  }