@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.
- package/.turbo/turbo-build.log +26 -18
- package/CHANGELOG.md +50 -0
- package/LICENSE +7 -0
- package/dist/324.runtime.umd.js +12 -12
- package/dist/{490.runtime.umd.js → 456.runtime.umd.js} +40 -40
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/runtime.umd.js +1037 -346
- package/dist/utilities/component-styles.d.ts +2 -1
- package/dist/utilities/component-styles.d.ts.map +1 -1
- package/dist/utilities/component-styles.js +79 -0
- package/dist/utilities/component-styles.js.map +1 -1
- package/dist/utilities/library-loader.d.ts.map +1 -1
- package/dist/utilities/library-loader.js +20 -5
- package/dist/utilities/library-loader.js.map +1 -1
- package/package.json +17 -17
- package/src/__tests__/component-styles.test.ts +252 -0
- package/src/__tests__/library-loader-load-order.test.ts +367 -0
- package/src/index.ts +2 -1
- package/src/utilities/component-styles.ts +130 -2
- package/src/utilities/library-loader.ts +32 -9
|
@@ -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
|
+
});
|