@memberjunction/react-runtime 5.49.0 → 5.51.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.
@@ -0,0 +1,367 @@
1
+ /**
2
+ * @fileoverview Tests for library load-order guarantees in LibraryLoader.
3
+ *
4
+ * The critical invariant: React MUST execute before ReactDOM because ReactDOM's
5
+ * UMD factory captures `window.React` at execution time. If ReactDOM executes
6
+ * first, it gets `undefined` for React and `createRoot` is permanently broken.
7
+ *
8
+ * These tests mock the script-loading layer to:
9
+ * 1. Prove the current (fixed) code always loads React before ReactDOM.
10
+ * 2. Simulate the old parallel-loading race condition and show it can fail.
11
+ * 3. Validate that post-load assertions catch broken ReactDOM objects.
12
+ */
13
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Window / document stubs (we're in a Node environment)
17
+ // ---------------------------------------------------------------------------
18
+ const fakeWindow: Record<string, unknown> = {};
19
+
20
+ vi.stubGlobal('window', fakeWindow);
21
+ vi.stubGlobal('document', {
22
+ createElement: vi.fn().mockReturnValue({
23
+ addEventListener: vi.fn(),
24
+ removeEventListener: vi.fn(),
25
+ parentNode: null,
26
+ }),
27
+ head: {
28
+ appendChild: vi.fn(),
29
+ },
30
+ querySelector: vi.fn().mockReturnValue(null),
31
+ });
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Mock dependencies that LibraryLoader imports
35
+ // ---------------------------------------------------------------------------
36
+ vi.mock('@memberjunction/core-entities', () => ({
37
+ MJComponentLibraryEntity: class {},
38
+ }));
39
+
40
+ vi.mock('../utilities/resource-manager', () => ({
41
+ resourceManager: {
42
+ setTimeout: vi.fn((_id: string, fn: () => void, _ms: number) => { fn(); return 1; }),
43
+ registerDOMElement: vi.fn(),
44
+ addEventListener: vi.fn(),
45
+ cleanupComponent: vi.fn(),
46
+ },
47
+ }));
48
+
49
+ vi.mock('../utilities/standard-libraries', () => ({
50
+ StandardLibraryManager: {
51
+ setConfiguration: vi.fn(),
52
+ getConfiguration: vi.fn().mockReturnValue({ libraries: [], metadata: {} }),
53
+ getEnabledLibraries: vi.fn().mockReturnValue([]),
54
+ },
55
+ // Re-export the type so the import doesn't break
56
+ }));
57
+
58
+ vi.mock('../utilities/library-registry', () => ({
59
+ LibraryRegistry: class {},
60
+ }));
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Helpers
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /**
67
+ * Tracks the order in which "scripts" resolve, simulating async CDN downloads.
68
+ * Each call to `createResolver(name)` returns a promise + a `resolve` function
69
+ * the test can call to simulate the script finishing download and execution.
70
+ */
71
+ function createLoadOrderTracker() {
72
+ const order: string[] = [];
73
+ const resolvers = new Map<string, () => void>();
74
+
75
+ function createResolver(name: string): Promise<Record<string, unknown>> {
76
+ return new Promise<Record<string, unknown>>(resolve => {
77
+ resolvers.set(name, () => {
78
+ order.push(name);
79
+ const fakeGlobal: Record<string, unknown> = { __name: name };
80
+ if (name === 'ReactDOM') {
81
+ // Simulate UMD behavior: createRoot only works if React was loaded first
82
+ if (fakeWindow.React) {
83
+ fakeGlobal.createRoot = function mockCreateRoot() {
84
+ return { unmount: vi.fn() };
85
+ };
86
+ }
87
+ // If React isn't on window yet, createRoot is missing — the real bug
88
+ }
89
+ fakeWindow[name] = fakeGlobal;
90
+ resolve(fakeGlobal);
91
+ });
92
+ });
93
+ }
94
+
95
+ return { order, resolvers, createResolver };
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Import the module under test AFTER mocks are set up
100
+ // ---------------------------------------------------------------------------
101
+ import { LibraryLoader } from '../utilities/library-loader';
102
+
103
+ describe('LibraryLoader — load order guarantees', () => {
104
+ beforeEach(() => {
105
+ // Clean globals between tests
106
+ delete fakeWindow.React;
107
+ delete fakeWindow.ReactDOM;
108
+ delete fakeWindow.Babel;
109
+ delete fakeWindow.PropTypes;
110
+
111
+ // Reset the static loadedResources cache so each test starts clean
112
+ LibraryLoader.getLoadedResources().clear();
113
+ });
114
+
115
+ afterEach(() => {
116
+ vi.restoreAllMocks();
117
+ });
118
+
119
+ // -----------------------------------------------------------------------
120
+ // Test 1: React resolves before ReactDOM in the fixed code
121
+ // -----------------------------------------------------------------------
122
+ it('should load React before ReactDOM (sequential phase 1 → phase 2)', async () => {
123
+ const tracker = createLoadOrderTracker();
124
+
125
+ // Spy on the private static loadScript to intercept calls and control
126
+ // resolution order. We use `spyOn` + `mockImplementation` so we can
127
+ // see WHEN each library's loadScript is first called.
128
+ const callOrder: string[] = [];
129
+
130
+ const loadScriptSpy = vi.spyOn(LibraryLoader as unknown as { loadScript: (...args: unknown[]) => Promise<unknown> }, 'loadScript' as never)
131
+ .mockImplementation((_url: unknown, globalName: unknown) => {
132
+ const name = globalName as string;
133
+ callOrder.push(name);
134
+ const p = tracker.createResolver(name);
135
+ // Simulate immediate resolution (CDN is fast) in call order.
136
+ // The key assertion is about WHEN loadScript is called, not when
137
+ // it resolves — if React's loadScript is awaited before ReactDOM's
138
+ // loadScript is even called, the ordering is guaranteed.
139
+ tracker.resolvers.get(name)!();
140
+ return p;
141
+ });
142
+
143
+ await LibraryLoader.loadLibrariesFromConfig(undefined, false);
144
+
145
+ // React must be the FIRST loadScript call
146
+ expect(callOrder[0]).toBe('React');
147
+
148
+ // ReactDOM must come AFTER React
149
+ const reactIndex = callOrder.indexOf('React');
150
+ const reactDOMIndex = callOrder.indexOf('ReactDOM');
151
+ expect(reactIndex).toBeLessThan(reactDOMIndex);
152
+
153
+ // Execution order (tracker.order) must also have React first
154
+ expect(tracker.order[0]).toBe('React');
155
+ const reactExecIdx = tracker.order.indexOf('React');
156
+ const reactDOMExecIdx = tracker.order.indexOf('ReactDOM');
157
+ expect(reactExecIdx).toBeLessThan(reactDOMExecIdx);
158
+
159
+ loadScriptSpy.mockRestore();
160
+ });
161
+
162
+ // -----------------------------------------------------------------------
163
+ // Test 2: ReactDOM gets a working createRoot when React loads first
164
+ // -----------------------------------------------------------------------
165
+ it('should produce a ReactDOM with createRoot when load order is correct', async () => {
166
+ const tracker = createLoadOrderTracker();
167
+
168
+ vi.spyOn(LibraryLoader as unknown as { loadScript: (...args: unknown[]) => Promise<unknown> }, 'loadScript' as never)
169
+ .mockImplementation((_url: unknown, globalName: unknown) => {
170
+ const name = globalName as string;
171
+ const p = tracker.createResolver(name);
172
+ // Resolve immediately — React first because of sequential await
173
+ tracker.resolvers.get(name)!();
174
+ return p;
175
+ });
176
+
177
+ const result = await LibraryLoader.loadLibrariesFromConfig(undefined, false);
178
+
179
+ // ReactDOM should have createRoot because React was available when it "executed"
180
+ expect(result.ReactDOM).toBeDefined();
181
+ expect((result.ReactDOM as Record<string, unknown>).createRoot).toBeDefined();
182
+ expect(typeof (result.ReactDOM as Record<string, unknown>).createRoot).toBe('function');
183
+ });
184
+
185
+ // -----------------------------------------------------------------------
186
+ // Test 3: Simulating the OLD race condition — ReactDOM executes first
187
+ // -----------------------------------------------------------------------
188
+ it('should demonstrate that ReactDOM lacks createRoot when it executes before React', async () => {
189
+ // This test does NOT use loadLibrariesFromConfig — it directly simulates
190
+ // the broken parallel behavior to prove the race condition is real.
191
+ const tracker = createLoadOrderTracker();
192
+
193
+ // Create promises for both
194
+ const reactPromise = tracker.createResolver('React');
195
+ const reactDOMPromise = tracker.createResolver('ReactDOM');
196
+
197
+ // Simulate the race: resolve ReactDOM FIRST (before React)
198
+ tracker.resolvers.get('ReactDOM')!();
199
+ tracker.resolvers.get('React')!();
200
+
201
+ const [, reactDOM] = await Promise.all([reactPromise, reactDOMPromise]);
202
+
203
+ // ReactDOM executed before React, so createRoot should be MISSING
204
+ expect((reactDOM as Record<string, unknown>).createRoot).toBeUndefined();
205
+
206
+ // Execution order confirms ReactDOM came first
207
+ expect(tracker.order[0]).toBe('ReactDOM');
208
+ expect(tracker.order[1]).toBe('React');
209
+ });
210
+
211
+ // -----------------------------------------------------------------------
212
+ // Test 4: Simulating correct order — ReactDOM executes after React
213
+ // -----------------------------------------------------------------------
214
+ it('should demonstrate that ReactDOM has createRoot when it executes after React', async () => {
215
+ const tracker = createLoadOrderTracker();
216
+
217
+ const reactPromise = tracker.createResolver('React');
218
+ const reactDOMPromise = tracker.createResolver('ReactDOM');
219
+
220
+ // Correct order: React first, then ReactDOM
221
+ tracker.resolvers.get('React')!();
222
+ tracker.resolvers.get('ReactDOM')!();
223
+
224
+ const [, reactDOM] = await Promise.all([reactPromise, reactDOMPromise]);
225
+
226
+ // ReactDOM executed after React, so createRoot should be present
227
+ expect((reactDOM as Record<string, unknown>).createRoot).toBeDefined();
228
+ expect(typeof (reactDOM as Record<string, unknown>).createRoot).toBe('function');
229
+ });
230
+
231
+ // -----------------------------------------------------------------------
232
+ // Test 5: ReactDOM and Babel load in parallel (phase 2), both after React
233
+ // -----------------------------------------------------------------------
234
+ it('should load ReactDOM and Babel in parallel after React completes', async () => {
235
+ const callTimestamps: { name: string; time: number }[] = [];
236
+ const startTime = Date.now();
237
+
238
+ vi.spyOn(LibraryLoader as unknown as { loadScript: (...args: unknown[]) => Promise<unknown> }, 'loadScript' as never)
239
+ .mockImplementation((_url: unknown, globalName: unknown) => {
240
+ const name = globalName as string;
241
+ callTimestamps.push({ name, time: Date.now() - startTime });
242
+
243
+ // Simulate globals
244
+ const fakeGlobal: Record<string, unknown> = { __name: name };
245
+ if (name === 'ReactDOM') {
246
+ fakeGlobal.createRoot = vi.fn();
247
+ }
248
+ fakeWindow[name] = fakeGlobal;
249
+ return Promise.resolve(fakeGlobal);
250
+ });
251
+
252
+ await LibraryLoader.loadLibrariesFromConfig(undefined, false);
253
+
254
+ // React is called first
255
+ expect(callTimestamps[0].name).toBe('React');
256
+
257
+ // ReactDOM and Babel are both called after React, and they can be in either order
258
+ const phase2Names = callTimestamps.slice(1).map(t => t.name);
259
+ expect(phase2Names).toContain('ReactDOM');
260
+ expect(phase2Names).toContain('Babel');
261
+ });
262
+
263
+ // -----------------------------------------------------------------------
264
+ // Test 6: Post-load validation catches missing createRoot
265
+ // -----------------------------------------------------------------------
266
+ it('should detect when ReactDOM.createRoot is missing (validation check)', () => {
267
+ // Simulate a broken ReactDOM object (loaded before React)
268
+ const brokenReactDOM = { __name: 'ReactDOM' }; // no createRoot
269
+
270
+ // The validation check used by ReactBridgeService
271
+ const hasCreateRoot = brokenReactDOM != null &&
272
+ 'createRoot' in brokenReactDOM &&
273
+ typeof (brokenReactDOM as Record<string, unknown>).createRoot === 'function';
274
+
275
+ expect(hasCreateRoot).toBe(false);
276
+ });
277
+
278
+ it('should detect when ReactDOM.createRoot is present (validation check)', () => {
279
+ // Simulate a working ReactDOM object (loaded after React)
280
+ const workingReactDOM = {
281
+ __name: 'ReactDOM',
282
+ createRoot: function mockCreateRoot() { return { unmount: vi.fn() }; }
283
+ };
284
+
285
+ const hasCreateRoot = workingReactDOM != null &&
286
+ 'createRoot' in workingReactDOM &&
287
+ typeof (workingReactDOM as Record<string, unknown>).createRoot === 'function';
288
+
289
+ expect(hasCreateRoot).toBe(true);
290
+ });
291
+
292
+ // -----------------------------------------------------------------------
293
+ // Test 7: Retry after destroy resets adapter properly
294
+ // -----------------------------------------------------------------------
295
+ it('should demonstrate that clearing initializationPromise allows re-initialization', async () => {
296
+ // Simulates the AngularAdapterService pattern
297
+ let initCount = 0;
298
+ let initPromise: Promise<void> | undefined;
299
+ let runtime: { version: string } | undefined;
300
+
301
+ async function doInit(): Promise<void> {
302
+ initCount++;
303
+ runtime = { version: `v${initCount}` };
304
+ }
305
+
306
+ async function initialize(): Promise<void> {
307
+ if (runtime) return;
308
+ if (initPromise) return initPromise;
309
+ initPromise = doInit();
310
+ await initPromise;
311
+ }
312
+
313
+ function destroy(): void {
314
+ runtime = undefined;
315
+ initPromise = undefined; // THE FIX — without this, re-init doesn't run
316
+ }
317
+
318
+ // First init
319
+ await initialize();
320
+ expect(initCount).toBe(1);
321
+ expect(runtime?.version).toBe('v1');
322
+
323
+ // Destroy
324
+ destroy();
325
+ expect(runtime).toBeUndefined();
326
+
327
+ // Re-init should actually run doInit again
328
+ await initialize();
329
+ expect(initCount).toBe(2);
330
+ expect(runtime?.version).toBe('v2');
331
+ });
332
+
333
+ it('should demonstrate the BUG when initializationPromise is NOT cleared', async () => {
334
+ let initCount = 0;
335
+ let initPromise: Promise<void> | undefined;
336
+ let runtime: { version: string } | undefined;
337
+
338
+ async function doInit(): Promise<void> {
339
+ initCount++;
340
+ runtime = { version: `v${initCount}` };
341
+ }
342
+
343
+ async function initialize(): Promise<void> {
344
+ if (runtime) return;
345
+ if (initPromise) return initPromise; // BUG: returns stale resolved promise
346
+ initPromise = doInit();
347
+ await initPromise;
348
+ }
349
+
350
+ function destroyBuggy(): void {
351
+ runtime = undefined;
352
+ // BUG: initPromise is NOT cleared
353
+ }
354
+
355
+ // First init
356
+ await initialize();
357
+ expect(initCount).toBe(1);
358
+
359
+ // Destroy (buggy version)
360
+ destroyBuggy();
361
+
362
+ // Re-init — this silently does nothing because initPromise is still set
363
+ await initialize();
364
+ expect(initCount).toBe(1); // Still 1! doInit never ran again
365
+ expect(runtime).toBeUndefined(); // runtime is still undefined — broken state
366
+ });
367
+ });
@@ -133,16 +133,39 @@ export class LibraryLoader {
133
133
  * Load libraries based on the current configuration
134
134
  */
135
135
  static async loadLibrariesFromConfig(options?: ConfigLoadOptions, debug?: boolean): Promise<LibraryLoadResult> {
136
- // Always load core runtime libraries first
136
+ // Load core runtime libraries in dependency order.
137
+ // ReactDOM's UMD factory captures `window.React` at execution time,
138
+ // so React MUST execute before ReactDOM. Loading them in parallel with
139
+ // async scripts causes an intermittent race condition where ReactDOM
140
+ // executes first and gets `undefined` for React, permanently breaking
141
+ // `createRoot` on that object instance.
137
142
  const coreLibraries = getCoreRuntimeLibraries(debug);
138
- const corePromises = coreLibraries.map(lib =>
139
- this.loadScript(lib.cdnUrl, lib.globalVariable, debug, lib.fallbackCdnUrls)
140
- );
141
-
142
- const coreResults = await Promise.all(corePromises);
143
- const React = coreResults.find((_, i) => coreLibraries[i].globalVariable === 'React');
144
- const ReactDOM = coreResults.find((_, i) => coreLibraries[i].globalVariable === 'ReactDOM');
145
- const Babel = coreResults.find((_, i) => coreLibraries[i].globalVariable === 'Babel');
143
+ const reactLib = coreLibraries.find(lib => lib.globalVariable === 'React');
144
+ const reactDOMLib = coreLibraries.find(lib => lib.globalVariable === 'ReactDOM');
145
+ const babelLib = coreLibraries.find(lib => lib.globalVariable === 'Babel');
146
+
147
+ // Phase 1: React must load first (ReactDOM depends on it)
148
+ let React: unknown;
149
+ if (reactLib) {
150
+ React = await this.loadScript(reactLib.cdnUrl, reactLib.globalVariable, debug, reactLib.fallbackCdnUrls);
151
+ }
152
+
153
+ // Phase 2: ReactDOM and Babel can load in parallel (both only depend on React)
154
+ const phase2Promises: Promise<unknown>[] = [];
155
+ const phase2Labels: string[] = [];
156
+
157
+ if (reactDOMLib) {
158
+ phase2Promises.push(this.loadScript(reactDOMLib.cdnUrl, reactDOMLib.globalVariable, debug, reactDOMLib.fallbackCdnUrls));
159
+ phase2Labels.push('ReactDOM');
160
+ }
161
+ if (babelLib) {
162
+ phase2Promises.push(this.loadScript(babelLib.cdnUrl, babelLib.globalVariable, debug, babelLib.fallbackCdnUrls));
163
+ phase2Labels.push('Babel');
164
+ }
165
+
166
+ const phase2Results = await Promise.all(phase2Promises);
167
+ const ReactDOM = phase2Labels.indexOf('ReactDOM') >= 0 ? phase2Results[phase2Labels.indexOf('ReactDOM')] : undefined;
168
+ const Babel = phase2Labels.indexOf('Babel') >= 0 ? phase2Results[phase2Labels.indexOf('Babel')] : undefined;
146
169
 
147
170
  // Expose React and ReactDOM as globals for UMD libraries that expect them
148
171
  // Many React component libraries (Recharts, Victory, etc.) expect these as globals