@memberjunction/react-runtime 5.50.0 → 6.1.0-edge.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,252 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { ComponentStyles, StyleOverrides } from '@memberjunction/interactive-component-types';
3
+ import { SetupStyles, BuildStylesFromTheme, ApplyStyleOverrides } from '../utilities/component-styles';
4
+
5
+ /**
6
+ * Installs a `getComputedStyle` stub resolving the given token map, so the theme
7
+ * bridge can be exercised in the node test environment (which has no DOM).
8
+ * Returns a throwaway element to pass as the bridge's `root`.
9
+ */
10
+ function stubTheme(tokens: Record<string, string>): Element {
11
+ (globalThis as Record<string, unknown>).getComputedStyle = () => ({
12
+ getPropertyValue: (token: string) => tokens[token] ?? '',
13
+ });
14
+ return {} as Element;
15
+ }
16
+
17
+ afterEach(() => {
18
+ delete (globalThis as Record<string, unknown>).getComputedStyle;
19
+ });
20
+
21
+ describe('SetupStyles visualization defaults', () => {
22
+ it('ships a multi-stop sequential ramp', () => {
23
+ const scale = SetupStyles().sequentialScale;
24
+ expect(scale && scale.length).toBeGreaterThan(1);
25
+ });
26
+
27
+ it('ships a diverging ramp with both endpoints', () => {
28
+ const diverging = SetupStyles().divergingScale;
29
+ expect(diverging?.low).toBeTruthy();
30
+ expect(diverging?.high).toBeTruthy();
31
+ });
32
+ });
33
+
34
+ describe('SetupStyles overlay and status defaults', () => {
35
+ it('ships an overlay scrim and status text/border colors', () => {
36
+ const colors = SetupStyles().colors;
37
+ expect(colors.overlay).toBeTruthy();
38
+ for (const key of [
39
+ 'successText', 'successBorder',
40
+ 'warningText', 'warningBorder',
41
+ 'errorText', 'errorBorder',
42
+ 'infoText', 'infoBorder',
43
+ ]) {
44
+ expect(colors[key]).toBeTruthy();
45
+ }
46
+ });
47
+ });
48
+
49
+ describe('BuildStylesFromTheme visualization ramps', () => {
50
+ it('populates both ramps from --mj-viz-seq-* / --mj-viz-div-* tokens', () => {
51
+ const root = stubTheme({
52
+ '--mj-viz-seq-1': '#eef', '--mj-viz-seq-2': '#99f', '--mj-viz-seq-3': '#22a',
53
+ '--mj-viz-div-low': '#c00', '--mj-viz-div-mid': '#eee', '--mj-viz-div-high': '#0c0',
54
+ });
55
+
56
+ const styles = BuildStylesFromTheme(root);
57
+
58
+ expect(styles.sequentialScale).toEqual(['#eef', '#99f', '#22a']);
59
+ expect(styles.divergingScale).toEqual({ low: '#c00', mid: '#eee', high: '#0c0' });
60
+ });
61
+
62
+ it('omits mid when the theme does not define it', () => {
63
+ const root = stubTheme({ '--mj-viz-div-low': '#c00', '--mj-viz-div-high': '#0c0' });
64
+
65
+ expect(BuildStylesFromTheme(root).divergingScale).toEqual({ low: '#c00', high: '#0c0' });
66
+ });
67
+
68
+ it('keeps the default ramp when only one sequential stop resolves', () => {
69
+ // A single stop cannot be interpolated, so a partially-themed page should
70
+ // fall back rather than render a one-color "ramp".
71
+ const root = stubTheme({ '--mj-viz-seq-1': '#eef' });
72
+
73
+ expect(BuildStylesFromTheme(root).sequentialScale).toEqual(SetupStyles().sequentialScale);
74
+ });
75
+
76
+ it('keeps the default diverging ramp when an endpoint is missing', () => {
77
+ const root = stubTheme({ '--mj-viz-div-low': '#c00' });
78
+
79
+ expect(BuildStylesFromTheme(root).divergingScale).toEqual(SetupStyles().divergingScale);
80
+ });
81
+ });
82
+
83
+ describe('BuildStylesFromTheme overlay and status bridging', () => {
84
+ it('bridges the overlay scrim from --mj-bg-overlay', () => {
85
+ const root = stubTheme({ '--mj-bg-overlay': 'rgba(15, 23, 42, 0.5)' });
86
+
87
+ expect(BuildStylesFromTheme(root).colors.overlay).toBe('rgba(15, 23, 42, 0.5)');
88
+ });
89
+
90
+ it('bridges status text and border tokens', () => {
91
+ const root = stubTheme({
92
+ '--mj-status-success-text': '#0a7d43',
93
+ '--mj-status-error-border': '#f5c2c0',
94
+ });
95
+
96
+ const styles = BuildStylesFromTheme(root);
97
+
98
+ expect(styles.colors.successText).toBe('#0a7d43');
99
+ expect(styles.colors.errorBorder).toBe('#f5c2c0');
100
+ });
101
+
102
+ it('bridges the secondary palette from --mj-brand-secondary tokens', () => {
103
+ const root = stubTheme({
104
+ '--mj-brand-secondary': '#092340',
105
+ '--mj-brand-secondary-hover': '#004a71',
106
+ });
107
+
108
+ const styles = BuildStylesFromTheme(root);
109
+
110
+ expect(styles.colors.secondary).toBe('#092340');
111
+ expect(styles.colors.secondaryHover).toBe('#004a71');
112
+ });
113
+
114
+ it('keeps the defaults when the tokens are absent', () => {
115
+ const root = stubTheme({});
116
+ const styles = BuildStylesFromTheme(root);
117
+ const defaults = SetupStyles().colors;
118
+
119
+ expect(styles.colors.overlay).toBe(defaults.overlay);
120
+ expect(styles.colors.warningText).toBe(defaults.warningText);
121
+ });
122
+ });
123
+
124
+ describe('ApplyStyleOverrides', () => {
125
+ const base = (): ComponentStyles => SetupStyles();
126
+ const userRequest = (partial: Partial<StyleOverrides>): StyleOverrides =>
127
+ ({ ...partial, source: 'user-request' });
128
+
129
+ it('returns the base untouched when there are no overrides', () => {
130
+ const input = base();
131
+ expect(ApplyStyleOverrides(input, undefined)).toBe(input);
132
+ });
133
+
134
+ it('returns the base untouched when no slot carries a usable value', () => {
135
+ const input = base();
136
+ expect(ApplyStyleOverrides(input, userRequest({ chartPalette: [] }))).toBe(input);
137
+ });
138
+
139
+ it('replaces the chart palette with the requested colors', () => {
140
+ const styles = ApplyStyleOverrides(base(), userRequest({ chartPalette: ['#00f', '#fc0'] }));
141
+ expect(styles.chartPalette).toEqual(['#00f', '#fc0']);
142
+ });
143
+
144
+ it('replaces the diverging ramp for a red-to-green request', () => {
145
+ const styles = ApplyStyleOverrides(base(), userRequest({ divergingScale: { low: '#f00', high: '#0f0' } }));
146
+ expect(styles.divergingScale).toEqual({ low: '#f00', high: '#0f0' });
147
+ });
148
+
149
+ it('ignores a diverging override missing an endpoint', () => {
150
+ const overrides = userRequest({ divergingScale: { low: '#f00', high: '' } });
151
+ expect(ApplyStyleOverrides(base(), overrides).divergingScale).toEqual(SetupStyles().divergingScale);
152
+ });
153
+
154
+ it('ignores a single-stop sequential override', () => {
155
+ const styles = ApplyStyleOverrides(base(), userRequest({ sequentialScale: ['#00f'] }));
156
+ expect(styles.sequentialScale).toEqual(SetupStyles().sequentialScale);
157
+ });
158
+
159
+ it('leaves non-visualization tokens alone', () => {
160
+ const input = base();
161
+ const styles = ApplyStyleOverrides(input, userRequest({ chartPalette: ['#00f'] }));
162
+ expect(styles.colors.primary).toBe(input.colors.primary);
163
+ expect(styles.typography.fontFamily).toBe(input.typography.fontFamily);
164
+ });
165
+
166
+ it('does not mutate the base styles', () => {
167
+ const input = base();
168
+ const original = [...(input.chartPalette ?? [])];
169
+ ApplyStyleOverrides(input, userRequest({ chartPalette: ['#00f', '#fc0'] }));
170
+ expect(input.chartPalette).toEqual(original);
171
+ });
172
+
173
+ it('applies overrides over theme-bridged styles, not just defaults', () => {
174
+ const root = stubTheme({ '--mj-viz-1': '#111', '--mj-viz-2': '#222' });
175
+ const themed = BuildStylesFromTheme(root);
176
+
177
+ const styles = ApplyStyleOverrides(themed, userRequest({ chartPalette: ['#00f'] }));
178
+
179
+ expect(styles.chartPalette).toEqual(['#00f']);
180
+ // The theme still supplies everything the user did not ask about.
181
+ expect(styles.colors.primary).toBe(themed.colors.primary);
182
+ });
183
+ });
184
+
185
+ describe('ApplyStyleOverrides fontScale', () => {
186
+ const orgDefault = (partial: Partial<StyleOverrides>): StyleOverrides =>
187
+ ({ ...partial, source: 'organization-default' });
188
+
189
+ it('scales every fontSize token up together for large', () => {
190
+ // The point of doing this here rather than in the generator: one factor, whole ladder.
191
+ const styles = ApplyStyleOverrides(SetupStyles(), orgDefault({ fontScale: 'large' }));
192
+ expect(styles.typography.fontSize).toEqual({
193
+ xs: '14px', sm: '15px', md: '18px', lg: '20px', xl: '25px', xxl: '30px', xxxl: '40px',
194
+ });
195
+ });
196
+
197
+ it('scales every fontSize token down together for small', () => {
198
+ const styles = ApplyStyleOverrides(SetupStyles(), orgDefault({ fontScale: 'small' }));
199
+ expect(styles.typography.fontSize).toEqual({
200
+ xs: '10px', sm: '11px', md: '12px', lg: '14px', xl: '18px', xxl: '21px', xxxl: '28px',
201
+ });
202
+ });
203
+
204
+ it('treats normal as no override at all', () => {
205
+ const input = SetupStyles();
206
+ expect(ApplyStyleOverrides(input, orgDefault({ fontScale: 'normal' }))).toBe(input);
207
+ });
208
+
209
+ it('ignores an unrecognized scale rather than guessing a factor', () => {
210
+ const input = SetupStyles();
211
+ const overrides = orgDefault({ fontScale: 'huge' as unknown as 'large' });
212
+ expect(ApplyStyleOverrides(input, overrides)).toBe(input);
213
+ });
214
+
215
+ it('holds a floor so small cannot render text illegible', () => {
216
+ const base = SetupStyles();
217
+ base.typography.fontSize = { xs: '9px', sm: '12px', md: '14px', lg: '16px', xl: '20px' };
218
+ const styles = ApplyStyleOverrides(base, orgDefault({ fontScale: 'small' }));
219
+ expect(styles.typography.fontSize.xs).toBe('10px');
220
+ });
221
+
222
+ it('leaves sizes not expressed in px alone rather than guessing', () => {
223
+ const base = SetupStyles();
224
+ base.typography.fontSize = { sm: '0.875rem', md: '14px', lg: 'clamp(1rem, 2vw, 2rem)', xl: '20px' };
225
+ const styles = ApplyStyleOverrides(base, orgDefault({ fontScale: 'large' }));
226
+ expect(styles.typography.fontSize).toEqual({
227
+ sm: '0.875rem', md: '18px', lg: 'clamp(1rem, 2vw, 2rem)', xl: '25px',
228
+ });
229
+ });
230
+
231
+ it('keeps fontFamily, weights, spacing and colors untouched', () => {
232
+ const input = SetupStyles();
233
+ const styles = ApplyStyleOverrides(input, orgDefault({ fontScale: 'large' }));
234
+ expect(styles.typography.fontFamily).toBe(input.typography.fontFamily);
235
+ expect(styles.typography.fontWeight).toEqual(input.typography.fontWeight);
236
+ expect(styles.spacing).toEqual(input.spacing);
237
+ expect(styles.colors.primary).toBe(input.colors.primary);
238
+ });
239
+
240
+ it('does not mutate the base ladder', () => {
241
+ const input = SetupStyles();
242
+ ApplyStyleOverrides(input, orgDefault({ fontScale: 'large' }));
243
+ expect(input.typography.fontSize.md).toBe('14px');
244
+ });
245
+
246
+ it('combines with a color override in one pass', () => {
247
+ const styles = ApplyStyleOverrides(
248
+ SetupStyles(), orgDefault({ fontScale: 'small', chartPalette: ['#00f', '#fc0'] }));
249
+ expect(styles.chartPalette).toEqual(['#00f', '#fc0']);
250
+ expect(styles.typography.fontSize.md).toBe('12px');
251
+ });
252
+ });
@@ -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
+ });
package/src/index.ts CHANGED
@@ -89,7 +89,8 @@ export {
89
89
  export {
90
90
  SetupStyles,
91
91
  createDefaultComponentStyles,
92
- BuildStylesFromTheme
92
+ BuildStylesFromTheme,
93
+ ApplyStyleOverrides
93
94
  } from './utilities/component-styles';
94
95
 
95
96
  export {