@memberjunction/react-runtime 5.31.0 → 5.33.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 +8 -8
- package/CHANGELOG.md +29 -0
- package/dist/324.runtime.umd.js +3 -2
- package/dist/component-manager/component-manager.d.ts.map +1 -1
- package/dist/component-manager/component-manager.js +16 -2
- package/dist/component-manager/component-manager.js.map +1 -1
- package/dist/runtime.umd.js +2242 -195
- package/package.json +6 -6
- package/src/__tests__/component-manager.test.ts +340 -0
- package/src/component-manager/component-manager.ts +26 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@memberjunction/react-runtime",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.33.0",
|
|
4
4
|
"description": "Platform-agnostic React component runtime for MemberJunction. Provides core compilation, registry, and execution capabilities for React components in any JavaScript environment.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -29,11 +29,11 @@
|
|
|
29
29
|
},
|
|
30
30
|
"homepage": "https://github.com/MemberJunction/MJ#readme",
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@memberjunction/core": "5.
|
|
33
|
-
"@memberjunction/global": "5.
|
|
34
|
-
"@memberjunction/interactive-component-types": "5.
|
|
35
|
-
"@memberjunction/core-entities": "5.
|
|
36
|
-
"@memberjunction/graphql-dataprovider": "5.
|
|
32
|
+
"@memberjunction/core": "5.33.0",
|
|
33
|
+
"@memberjunction/global": "5.33.0",
|
|
34
|
+
"@memberjunction/interactive-component-types": "5.33.0",
|
|
35
|
+
"@memberjunction/core-entities": "5.33.0",
|
|
36
|
+
"@memberjunction/graphql-dataprovider": "5.33.0",
|
|
37
37
|
"@babel/standalone": "^7.29.1",
|
|
38
38
|
"rxjs": "^7.8.2"
|
|
39
39
|
},
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Stub globals before imports
|
|
4
|
+
vi.stubGlobal('window', {
|
|
5
|
+
setInterval: vi.fn().mockReturnValue(1),
|
|
6
|
+
clearInterval: vi.fn(),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// Mock ComponentMetadataEngine before importing ComponentManager
|
|
10
|
+
vi.mock('@memberjunction/core-entities', () => ({
|
|
11
|
+
ComponentMetadataEngine: {
|
|
12
|
+
Instance: {
|
|
13
|
+
Config: vi.fn().mockResolvedValue(undefined),
|
|
14
|
+
Components: [],
|
|
15
|
+
ComponentLibraries: [],
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
MJComponentLibraryEntity: class {},
|
|
19
|
+
MJComponentEntityExtended: class {},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('@memberjunction/core', () => ({
|
|
23
|
+
UserInfo: class {},
|
|
24
|
+
Metadata: class {},
|
|
25
|
+
LogError: vi.fn(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import { ComponentManager } from '../component-manager/component-manager';
|
|
29
|
+
import { ComponentRegistry } from '../registry/component-registry';
|
|
30
|
+
import { ComponentSpec } from '@memberjunction/interactive-component-types';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Creates a minimal mock ComponentCompiler.
|
|
34
|
+
* The compile() result returns a factory that produces a tagged component object
|
|
35
|
+
* so tests can verify which code was actually compiled.
|
|
36
|
+
*/
|
|
37
|
+
function createMockCompiler() {
|
|
38
|
+
return {
|
|
39
|
+
compile: vi.fn().mockImplementation(async (opts: { componentName: string; componentCode: string }) => ({
|
|
40
|
+
success: true,
|
|
41
|
+
component: {
|
|
42
|
+
factory: () => ({ __name: opts.componentName, __code: opts.componentCode }),
|
|
43
|
+
},
|
|
44
|
+
loadedLibraries: new Map(),
|
|
45
|
+
})),
|
|
46
|
+
setBabelInstance: vi.fn(),
|
|
47
|
+
} as any;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Creates a minimal mock RuntimeContext.
|
|
52
|
+
*/
|
|
53
|
+
function createMockRuntimeContext() {
|
|
54
|
+
return {
|
|
55
|
+
React: {},
|
|
56
|
+
globals: {},
|
|
57
|
+
libraries: {},
|
|
58
|
+
getLibrary: vi.fn(),
|
|
59
|
+
} as any;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Helper to build a ComponentSpec with sensible defaults.
|
|
64
|
+
*/
|
|
65
|
+
function makeSpec(overrides: Partial<ComponentSpec> & { name: string }): ComponentSpec {
|
|
66
|
+
return {
|
|
67
|
+
location: 'embedded',
|
|
68
|
+
version: '1.0.0',
|
|
69
|
+
namespace: 'Test',
|
|
70
|
+
code: `function ${overrides.name}() { return null; }`,
|
|
71
|
+
...overrides,
|
|
72
|
+
} as ComponentSpec;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('ComponentManager', () => {
|
|
76
|
+
let compiler: ReturnType<typeof createMockCompiler>;
|
|
77
|
+
let registry: ComponentRegistry;
|
|
78
|
+
let manager: ComponentManager;
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
compiler = createMockCompiler();
|
|
82
|
+
registry = new ComponentRegistry({ cleanupInterval: 0 });
|
|
83
|
+
manager = new ComponentManager(compiler, registry, createMockRuntimeContext(), {
|
|
84
|
+
debug: false,
|
|
85
|
+
enableUsageTracking: false,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// --------------------------------------------------------------------------
|
|
90
|
+
// Basic loading
|
|
91
|
+
// --------------------------------------------------------------------------
|
|
92
|
+
describe('loadComponent', () => {
|
|
93
|
+
it('should compile and return a component on first load', async () => {
|
|
94
|
+
const spec = makeSpec({ name: 'Button' });
|
|
95
|
+
const result = await manager.loadComponent(spec);
|
|
96
|
+
|
|
97
|
+
expect(result.success).toBe(true);
|
|
98
|
+
expect(result.fromCache).toBe(false);
|
|
99
|
+
expect(compiler.compile).toHaveBeenCalledTimes(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should return from cache on second load with same spec', async () => {
|
|
103
|
+
const spec = makeSpec({ name: 'Button' });
|
|
104
|
+
|
|
105
|
+
await manager.loadComponent(spec);
|
|
106
|
+
compiler.compile.mockClear();
|
|
107
|
+
|
|
108
|
+
const result2 = await manager.loadComponent(spec);
|
|
109
|
+
expect(result2.success).toBe(true);
|
|
110
|
+
expect(result2.fromCache).toBe(true);
|
|
111
|
+
expect(compiler.compile).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should recompile when code changes (different hash)', async () => {
|
|
115
|
+
const specV1 = makeSpec({ name: 'Button', code: 'function Button() { return 1; }' });
|
|
116
|
+
const specV2 = makeSpec({ name: 'Button', code: 'function Button() { return 2; }' });
|
|
117
|
+
|
|
118
|
+
await manager.loadComponent(specV1);
|
|
119
|
+
compiler.compile.mockClear();
|
|
120
|
+
|
|
121
|
+
const result = await manager.loadComponent(specV2);
|
|
122
|
+
expect(result.success).toBe(true);
|
|
123
|
+
expect(result.fromCache).toBe(false);
|
|
124
|
+
expect(compiler.compile).toHaveBeenCalledTimes(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should recompile when forceRecompile is set', async () => {
|
|
128
|
+
const spec = makeSpec({ name: 'Button' });
|
|
129
|
+
|
|
130
|
+
await manager.loadComponent(spec);
|
|
131
|
+
compiler.compile.mockClear();
|
|
132
|
+
|
|
133
|
+
const result = await manager.loadComponent(spec, { forceRecompile: true });
|
|
134
|
+
expect(result.success).toBe(true);
|
|
135
|
+
expect(result.fromCache).toBe(false);
|
|
136
|
+
expect(compiler.compile).toHaveBeenCalledTimes(1);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// --------------------------------------------------------------------------
|
|
141
|
+
// loadHierarchy – dependency resolution
|
|
142
|
+
// --------------------------------------------------------------------------
|
|
143
|
+
describe('loadHierarchy', () => {
|
|
144
|
+
it('should load root and its dependencies', async () => {
|
|
145
|
+
const childSpec = makeSpec({ name: 'ChildBtn', code: 'function ChildBtn() { return "child"; }' });
|
|
146
|
+
const rootSpec = makeSpec({
|
|
147
|
+
name: 'ParentForm',
|
|
148
|
+
code: 'function ParentForm() { return "parent"; }',
|
|
149
|
+
dependencies: [childSpec],
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const result = await manager.loadHierarchy(rootSpec);
|
|
153
|
+
|
|
154
|
+
expect(result.success).toBe(true);
|
|
155
|
+
expect(result.loadedComponents).toContain('ParentForm');
|
|
156
|
+
expect(result.loadedComponents).toContain('ChildBtn');
|
|
157
|
+
expect(compiler.compile).toHaveBeenCalledTimes(2);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should use updated dependency code even when root is cached', async () => {
|
|
161
|
+
const childV1 = makeSpec({ name: 'Search', code: 'function Search() { return "v1"; }' });
|
|
162
|
+
const rootSpec = makeSpec({
|
|
163
|
+
name: 'Dashboard',
|
|
164
|
+
code: 'function Dashboard() { return "root"; }',
|
|
165
|
+
dependencies: [childV1],
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// First load – populates cache
|
|
169
|
+
await manager.loadHierarchy(rootSpec);
|
|
170
|
+
expect(compiler.compile).toHaveBeenCalledTimes(2);
|
|
171
|
+
compiler.compile.mockClear();
|
|
172
|
+
|
|
173
|
+
// Second load – same root code, but updated Search dependency code
|
|
174
|
+
const childV2 = makeSpec({ name: 'Search', code: 'function Search() { return "v2"; }' });
|
|
175
|
+
const rootSpecUpdated = makeSpec({
|
|
176
|
+
name: 'Dashboard',
|
|
177
|
+
code: 'function Dashboard() { return "root"; }', // same root code
|
|
178
|
+
dependencies: [childV2],
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const result = await manager.loadHierarchy(rootSpecUpdated);
|
|
182
|
+
|
|
183
|
+
expect(result.success).toBe(true);
|
|
184
|
+
// Root should be cached, Search should recompile with new code
|
|
185
|
+
expect(result.stats?.fromCache).toBe(1); // root only
|
|
186
|
+
expect(compiler.compile).toHaveBeenCalledTimes(1); // Search recompiled
|
|
187
|
+
// Verify the recompiled dependency used the v2 code
|
|
188
|
+
const compileCall = compiler.compile.mock.calls[0][0];
|
|
189
|
+
expect(compileCall.componentCode).toContain('return "v2"');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should cache dependencies individually by content hash', async () => {
|
|
193
|
+
const child = makeSpec({ name: 'Grid', code: 'function Grid() { return "grid"; }' });
|
|
194
|
+
const root1 = makeSpec({
|
|
195
|
+
name: 'Page1',
|
|
196
|
+
code: 'function Page1() { return "p1"; }',
|
|
197
|
+
dependencies: [child],
|
|
198
|
+
});
|
|
199
|
+
const root2 = makeSpec({
|
|
200
|
+
name: 'Page2',
|
|
201
|
+
code: 'function Page2() { return "p2"; }',
|
|
202
|
+
dependencies: [{ ...child }], // same Grid dependency
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await manager.loadHierarchy(root1);
|
|
206
|
+
expect(compiler.compile).toHaveBeenCalledTimes(2); // Page1 + Grid
|
|
207
|
+
compiler.compile.mockClear();
|
|
208
|
+
|
|
209
|
+
// Loading Page2 with the exact same Grid dep should reuse Grid from cache
|
|
210
|
+
const result = await manager.loadHierarchy(root2);
|
|
211
|
+
expect(result.success).toBe(true);
|
|
212
|
+
expect(compiler.compile).toHaveBeenCalledTimes(1); // Only Page2 compiled, Grid cached
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should handle deeply nested dependency updates', async () => {
|
|
216
|
+
const leafV1 = makeSpec({ name: 'Leaf', code: 'function Leaf() { return "leaf-v1"; }' });
|
|
217
|
+
const mid = makeSpec({
|
|
218
|
+
name: 'Middle',
|
|
219
|
+
code: 'function Middle() { return "mid"; }',
|
|
220
|
+
dependencies: [leafV1],
|
|
221
|
+
});
|
|
222
|
+
const root = makeSpec({
|
|
223
|
+
name: 'Root',
|
|
224
|
+
code: 'function Root() { return "root"; }',
|
|
225
|
+
dependencies: [mid],
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// First load
|
|
229
|
+
await manager.loadHierarchy(root);
|
|
230
|
+
expect(compiler.compile).toHaveBeenCalledTimes(3);
|
|
231
|
+
compiler.compile.mockClear();
|
|
232
|
+
|
|
233
|
+
// Update only the leaf
|
|
234
|
+
const leafV2 = makeSpec({ name: 'Leaf', code: 'function Leaf() { return "leaf-v2"; }' });
|
|
235
|
+
const midUpdated = makeSpec({
|
|
236
|
+
name: 'Middle',
|
|
237
|
+
code: 'function Middle() { return "mid"; }',
|
|
238
|
+
dependencies: [leafV2],
|
|
239
|
+
});
|
|
240
|
+
const rootUpdated = makeSpec({
|
|
241
|
+
name: 'Root',
|
|
242
|
+
code: 'function Root() { return "root"; }',
|
|
243
|
+
dependencies: [midUpdated],
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const result = await manager.loadHierarchy(rootUpdated);
|
|
247
|
+
expect(result.success).toBe(true);
|
|
248
|
+
// Root cached, Middle cached, only Leaf recompiled
|
|
249
|
+
expect(compiler.compile).toHaveBeenCalledTimes(1);
|
|
250
|
+
const compileCall = compiler.compile.mock.calls[0][0];
|
|
251
|
+
expect(compileCall.componentCode).toContain('leaf-v2');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('should handle circular dependencies without infinite loop', async () => {
|
|
255
|
+
const specA = makeSpec({ name: 'CompA', code: 'function CompA() {}' });
|
|
256
|
+
const specB = makeSpec({
|
|
257
|
+
name: 'CompB',
|
|
258
|
+
code: 'function CompB() {}',
|
|
259
|
+
dependencies: [specA],
|
|
260
|
+
});
|
|
261
|
+
specA.dependencies = [specB];
|
|
262
|
+
|
|
263
|
+
const root = makeSpec({
|
|
264
|
+
name: 'Root',
|
|
265
|
+
code: 'function Root() {}',
|
|
266
|
+
dependencies: [specA],
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const result = await manager.loadHierarchy(root);
|
|
270
|
+
expect(result.success).toBe(true);
|
|
271
|
+
expect(result.loadedComponents).toContain('Root');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should fallback to cached spec dependencies when input has none', async () => {
|
|
275
|
+
const child = makeSpec({ name: 'Chart', code: 'function Chart() {}' });
|
|
276
|
+
const fullRootSpec = makeSpec({
|
|
277
|
+
name: 'Dashboard',
|
|
278
|
+
code: 'function Dashboard() {}',
|
|
279
|
+
dependencies: [child],
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// First load with full spec (has dependencies)
|
|
283
|
+
await manager.loadHierarchy(fullRootSpec);
|
|
284
|
+
expect(compiler.compile).toHaveBeenCalledTimes(2);
|
|
285
|
+
compiler.compile.mockClear();
|
|
286
|
+
|
|
287
|
+
// Second load with no dependencies field - simulates registry reference
|
|
288
|
+
const minimalSpec = makeSpec({
|
|
289
|
+
name: 'Dashboard',
|
|
290
|
+
code: 'function Dashboard() {}',
|
|
291
|
+
});
|
|
292
|
+
delete (minimalSpec as any).dependencies;
|
|
293
|
+
|
|
294
|
+
const result = await manager.loadHierarchy(minimalSpec);
|
|
295
|
+
expect(result.success).toBe(true);
|
|
296
|
+
expect(result.loadedComponents).toContain('Dashboard');
|
|
297
|
+
// Chart should still be loaded via cached spec's dependencies
|
|
298
|
+
expect(result.loadedComponents).toContain('Chart');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should prefer input dependencies over stale cached dependencies', async () => {
|
|
302
|
+
const depA = makeSpec({ name: 'DepA', code: 'function DepA() { return "A"; }' });
|
|
303
|
+
const root = makeSpec({
|
|
304
|
+
name: 'App',
|
|
305
|
+
code: 'function App() { return "app"; }',
|
|
306
|
+
dependencies: [depA],
|
|
307
|
+
});
|
|
308
|
+
await manager.loadHierarchy(root);
|
|
309
|
+
compiler.compile.mockClear();
|
|
310
|
+
|
|
311
|
+
// Same root code but dependency list changed (depB instead of depA)
|
|
312
|
+
const depB = makeSpec({ name: 'DepB', code: 'function DepB() { return "B"; }' });
|
|
313
|
+
const rootUpdated = makeSpec({
|
|
314
|
+
name: 'App',
|
|
315
|
+
code: 'function App() { return "app"; }',
|
|
316
|
+
dependencies: [depB],
|
|
317
|
+
});
|
|
318
|
+
const result = await manager.loadHierarchy(rootUpdated);
|
|
319
|
+
|
|
320
|
+
expect(result.success).toBe(true);
|
|
321
|
+
expect(result.loadedComponents).toContain('DepB');
|
|
322
|
+
expect(compiler.compile).toHaveBeenCalledTimes(1); // only DepB
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// --------------------------------------------------------------------------
|
|
327
|
+
// Cache management
|
|
328
|
+
// --------------------------------------------------------------------------
|
|
329
|
+
describe('clearCache', () => {
|
|
330
|
+
it('should clear the fetch cache', async () => {
|
|
331
|
+
const spec = makeSpec({ name: 'Widget' });
|
|
332
|
+
await manager.loadComponent(spec);
|
|
333
|
+
|
|
334
|
+
manager.clearCache();
|
|
335
|
+
|
|
336
|
+
const stats = manager.getCacheStats();
|
|
337
|
+
expect(stats.fetchCacheSize).toBe(0);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
});
|
|
@@ -84,6 +84,25 @@ export class ComponentManager {
|
|
|
84
84
|
// Check if already loading to prevent duplicate work
|
|
85
85
|
const existingPromise = this.loadingPromises.get(componentKey);
|
|
86
86
|
if (existingPromise && !options.forceRefresh) {
|
|
87
|
+
// Before returning the pending promise, check if the compiled component
|
|
88
|
+
// is already in the registry. This prevents deadlock in circular dependency
|
|
89
|
+
// chains: A's Step 6 loads B, B's Step 6 loads A again — but A was already
|
|
90
|
+
// compiled and registered (Step 5 runs before Step 6). Returning the pending
|
|
91
|
+
// promise would deadlock because it's waiting on its own call chain.
|
|
92
|
+
const namespace = spec.namespace || options.defaultNamespace || 'Global';
|
|
93
|
+
const version = spec.version || options.defaultVersion || 'latest';
|
|
94
|
+
const contentHash = this.calculateHash(spec);
|
|
95
|
+
const registered = this.registry.get(spec.name, namespace, version, contentHash);
|
|
96
|
+
if (registered) {
|
|
97
|
+
this.log(`Component already registered (circular dep resolution): ${spec.name}`);
|
|
98
|
+
return {
|
|
99
|
+
success: true,
|
|
100
|
+
component: registered,
|
|
101
|
+
spec,
|
|
102
|
+
fromCache: true
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
87
106
|
this.log(`Component already loading: ${spec.name}, waiting...`);
|
|
88
107
|
return existingPromise;
|
|
89
108
|
}
|
|
@@ -406,8 +425,13 @@ export class ComponentManager {
|
|
|
406
425
|
}
|
|
407
426
|
|
|
408
427
|
// Load dependencies
|
|
409
|
-
|
|
410
|
-
|
|
428
|
+
// Prefer the input spec's dependencies over the cached result's dependencies.
|
|
429
|
+
// When a parent component is served from cache, result.spec contains stale
|
|
430
|
+
// dependency code. The input spec has the latest dependency code from the caller.
|
|
431
|
+
// Each dependency still gets its own individual cache check via loadComponent.
|
|
432
|
+
const dependencies = spec.dependencies || result.spec?.dependencies;
|
|
433
|
+
if (dependencies) {
|
|
434
|
+
for (const dep of dependencies) {
|
|
411
435
|
// Normalize dependency spec for local registry lookup
|
|
412
436
|
const depSpec = { ...dep };
|
|
413
437
|
// OPTIMIZATION: If the dependency already has code (from registry population),
|