@memberjunction/react-runtime 5.30.1 → 5.31.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memberjunction/react-runtime",
3
- "version": "5.30.1",
3
+ "version": "5.31.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.30.1",
33
- "@memberjunction/global": "5.30.1",
34
- "@memberjunction/interactive-component-types": "5.30.1",
35
- "@memberjunction/core-entities": "5.30.1",
36
- "@memberjunction/graphql-dataprovider": "5.30.1",
32
+ "@memberjunction/core": "5.31.0",
33
+ "@memberjunction/global": "5.31.0",
34
+ "@memberjunction/interactive-component-types": "5.31.0",
35
+ "@memberjunction/core-entities": "5.31.0",
36
+ "@memberjunction/graphql-dataprovider": "5.31.0",
37
37
  "@babel/standalone": "^7.29.1",
38
38
  "rxjs": "^7.8.2"
39
39
  },
@@ -115,8 +115,9 @@ export class ComponentManager {
115
115
  // STEP 1: Check if already loaded in ComponentRegistry
116
116
  const namespace = spec.namespace || options.defaultNamespace || 'Global';
117
117
  const version = spec.version || options.defaultVersion || 'latest';
118
-
119
- const existing = this.registry.get(spec.name, namespace, version);
118
+ const contentHash = this.calculateHash(spec);
119
+
120
+ const existing = this.registry.get(spec.name, namespace, version, contentHash);
120
121
  if (existing && !options.forceRefresh && !options.forceRecompile) {
121
122
  this.log(`Component found in registry: ${spec.name}`);
122
123
 
@@ -149,7 +150,7 @@ export class ComponentManager {
149
150
  this.fetchCache.set(componentKey, {
150
151
  spec: fullSpec,
151
152
  fetchedAt: new Date(),
152
- hash: await this.calculateHash(fullSpec),
153
+ hash: this.calculateHash(fullSpec),
153
154
  usageNotified: false
154
155
  });
155
156
  } catch (error) {
@@ -173,7 +174,7 @@ export class ComponentManager {
173
174
  this.fetchCache.set(componentKey, {
174
175
  spec: fullSpec,
175
176
  fetchedAt: new Date(),
176
- hash: await this.calculateHash(fullSpec),
177
+ hash: this.calculateHash(fullSpec),
177
178
  usageNotified: false
178
179
  });
179
180
  }
@@ -203,11 +204,16 @@ export class ComponentManager {
203
204
  // STEP 5: Register in ComponentRegistry
204
205
  if (!existing || options.forceRefresh || options.forceRecompile) {
205
206
  this.log(`Registering component: ${spec.name}`);
207
+ // Register under the INPUT spec's hash so callers passing the same
208
+ // spec shape on subsequent loads hit the cache. If we keyed by the
209
+ // post-fetch fullSpec hash instead, registry-stub callers would
210
+ // never re-find the entry they just cached.
206
211
  this.registry.register(
207
212
  fullSpec.name,
208
213
  compiledComponent,
209
214
  namespace,
210
- version
215
+ version,
216
+ contentHash
211
217
  );
212
218
  }
213
219
 
@@ -720,9 +726,14 @@ export class ComponentManager {
720
726
  }
721
727
 
722
728
  /**
723
- * Calculate a hash for a component spec (for cache validation)
729
+ * Calculate a content fingerprint for a component spec. Used both as a
730
+ * cache-validation marker on `CacheEntry` and as part of the cache key so
731
+ * specs that share `(registry, namespace, name, version)` but carry
732
+ * different `code` (e.g. a registry-reference stub vs. an inline-code
733
+ * Studio export of the same artifact) do not collide on a single cache
734
+ * slot. Sync because callers use it to build keys in non-async paths.
724
735
  */
725
- private async calculateHash(spec: ComponentSpec): Promise<string> {
736
+ private calculateHash(spec: ComponentSpec): string {
726
737
  // Simple hash based on spec content
727
738
  const content = JSON.stringify({
728
739
  name: spec.name,
@@ -730,25 +741,30 @@ export class ComponentManager {
730
741
  code: spec.code,
731
742
  libraries: spec.libraries
732
743
  });
733
-
734
- // Simple hash function (in production, use crypto)
744
+
745
+ // FNV-style 32-bit integer hash
735
746
  let hash = 0;
736
747
  for (let i = 0; i < content.length; i++) {
737
748
  const char = content.charCodeAt(i);
738
749
  hash = ((hash << 5) - hash) + char;
739
- hash = hash & hash; // Convert to 32bit integer
750
+ hash = hash & hash;
740
751
  }
741
752
  return hash.toString(16);
742
753
  }
743
754
 
744
755
  /**
745
- * Generate a unique key for a component
756
+ * Generate a unique key for a component. Includes a content fingerprint so
757
+ * specs that share `(registry, namespace, name, version)` but carry
758
+ * different `code` (e.g. a registry-reference stub vs. an inline-code
759
+ * Studio export of the same artifact) are cached as separate entries
760
+ * instead of clobbering each other on a single slot.
746
761
  */
747
762
  private getComponentKey(spec: ComponentSpec, options: LoadOptions): string {
748
763
  const registry = spec.registry || 'local';
749
764
  const namespace = spec.namespace || options.defaultNamespace || 'Global';
750
765
  const version = spec.version || options.defaultVersion || 'latest';
751
- return `${registry}:${namespace}:${spec.name}:${version}`;
766
+ const hash = this.calculateHash(spec);
767
+ return `${registry}:${namespace}:${spec.name}:${version}#${hash}`;
752
768
  }
753
769
 
754
770
  /**
@@ -13,7 +13,7 @@ import {
13
13
  DependencyTree,
14
14
  RegistryComponentMetadata
15
15
  } from './registry-provider';
16
- import { UserInfo, Metadata } from '@memberjunction/core';
16
+ import { UserInfo, Metadata, IMetadataProvider } from '@memberjunction/core';
17
17
  import { UUIDsEqual } from '@memberjunction/global';
18
18
  import {
19
19
  MJComponentEntity,
@@ -72,6 +72,19 @@ export class ComponentRegistryService {
72
72
  private registryProviders = new Map<string, RegistryProvider>();
73
73
  private debug: boolean = false;
74
74
  private graphQLClient?: IComponentRegistryClient;
75
+ private _provider: IMetadataProvider | null = null;
76
+
77
+ /**
78
+ * Optional metadata provider override. Callers should set
79
+ * `instance.Provider = providerToUse` before invoking registry methods
80
+ * in multi-provider contexts. Falls back to the global default provider when unset.
81
+ */
82
+ public get Provider(): IMetadataProvider {
83
+ return this._provider ?? (new Metadata() as unknown as IMetadataProvider);
84
+ }
85
+ public set Provider(value: IMetadataProvider | null) {
86
+ this._provider = value;
87
+ }
75
88
 
76
89
  private constructor(
77
90
  compiler: ComponentCompiler,
@@ -153,7 +166,7 @@ export class ComponentRegistryService {
153
166
  const client = new GraphQLComponentRegistryClient(provider as GraphQLDataProvider);
154
167
  this.cachedProviderClient = client;
155
168
  if (this.debug) {
156
- console.log('📡 [ComponentRegistryService] Created GraphQL client from Metadata.Provider');
169
+ console.log('📡 [ComponentRegistryService] Created GraphQL client from active metadata provider');
157
170
  }
158
171
  return client;
159
172
  } catch (error) {
@@ -166,7 +179,7 @@ export class ComponentRegistryService {
166
179
  } catch (error) {
167
180
  // Provider might not be available in all environments
168
181
  if (this.debug) {
169
- console.log('⚠️ [ComponentRegistryService] Could not access Metadata.Provider:', error);
182
+ console.log('⚠️ [ComponentRegistryService] Could not access metadata provider:', error);
170
183
  }
171
184
  }
172
185
 
@@ -349,7 +362,7 @@ export class ComponentRegistryService {
349
362
  // Get GraphQL client - use provided one or fallback to Metadata.Provider
350
363
  const graphQLClient = await this.getGraphQLClient();
351
364
  if (!graphQLClient) {
352
- throw new Error('GraphQL client not available for external registry fetching. No client provided and Metadata.Provider is not a GraphQLDataProvider.');
365
+ throw new Error('GraphQL client not available for external registry fetching. No client provided and the active metadata provider is not a GraphQLDataProvider.');
353
366
  }
354
367
 
355
368
  // Check if we have a cached version first
@@ -585,7 +598,7 @@ export class ComponentRegistryService {
585
598
  contextUser?: UserInfo
586
599
  ): Promise<void> {
587
600
  // Get the actual entity object to save
588
- const md = new Metadata();
601
+ const md = this.Provider;
589
602
  const componentEntity = await md.GetEntityObject<MJComponentEntity>('MJ: Components', contextUser);
590
603
 
591
604
  // Load the existing component
@@ -53,6 +53,11 @@ export class ComponentRegistry {
53
53
  * @param component - Compiled component object
54
54
  * @param namespace - Component namespace (default: 'Global')
55
55
  * @param version - Component version (default: 'v1')
56
+ * @param contentHash - Optional content fingerprint. When provided, the entry
57
+ * is keyed by `(name, namespace, version, contentHash)` so multiple specs
58
+ * that share `(name, namespace, version)` but carry different `code` (e.g.
59
+ * a registry-reference stub vs. an inline-code Studio export of the same
60
+ * artifact) coexist in the cache instead of clobbering each other.
56
61
  * @param tags - Optional tags for categorization
57
62
  * @returns The registered component's metadata
58
63
  */
@@ -61,10 +66,11 @@ export class ComponentRegistry {
61
66
  component: ComponentObject,
62
67
  namespace: string = 'Global',
63
68
  version: string = 'v1',
69
+ contentHash?: string,
64
70
  tags?: string[]
65
71
  ): ComponentMetadata {
66
- const id = this.generateRegistryKey(name, namespace, version);
67
-
72
+ const id = this.generateRegistryKey(name, namespace, version, contentHash);
73
+
68
74
  // Create metadata
69
75
  const metadata: ComponentMetadata = {
70
76
  id,
@@ -99,12 +105,14 @@ export class ComponentRegistry {
99
105
  * @param name - Component name
100
106
  * @param namespace - Component namespace
101
107
  * @param version - Component version
108
+ * @param contentHash - Optional content fingerprint. When provided, looks up
109
+ * the exact `(name, namespace, version, contentHash)` entry. When omitted,
110
+ * falls back to the most recently registered entry matching the other keys
111
+ * (existing behavior).
102
112
  * @returns The component object if found, undefined otherwise
103
113
  */
104
- get(name: string, namespace: string = 'Global', version?: string): ComponentObject | undefined {
105
- const id = version
106
- ? this.generateRegistryKey(name, namespace, version)
107
- : this.findLatestVersion(name, namespace);
114
+ get(name: string, namespace: string = 'Global', version?: string, contentHash?: string): ComponentObject | undefined {
115
+ const id = this.resolveLookupKey(name, namespace, version, contentHash);
108
116
 
109
117
  if (!id) return undefined;
110
118
 
@@ -124,13 +132,11 @@ export class ComponentRegistry {
124
132
  * @param name - Component name
125
133
  * @param namespace - Component namespace
126
134
  * @param version - Component version
135
+ * @param contentHash - Optional content fingerprint (see {@link get})
127
136
  * @returns true if the component exists
128
137
  */
129
- has(name: string, namespace: string = 'Global', version?: string): boolean {
130
- const id = version
131
- ? this.generateRegistryKey(name, namespace, version)
132
- : this.findLatestVersion(name, namespace);
133
-
138
+ has(name: string, namespace: string = 'Global', version?: string, contentHash?: string): boolean {
139
+ const id = this.resolveLookupKey(name, namespace, version, contentHash);
134
140
  return id ? this.registry.has(id) : false;
135
141
  }
136
142
 
@@ -139,15 +145,12 @@ export class ComponentRegistry {
139
145
  * @param name - Component name
140
146
  * @param namespace - Component namespace
141
147
  * @param version - Component version
148
+ * @param contentHash - Optional content fingerprint (see {@link get})
142
149
  * @returns true if the component was removed
143
150
  */
144
- unregister(name: string, namespace: string = 'Global', version?: string): boolean {
145
- const id = version
146
- ? this.generateRegistryKey(name, namespace, version)
147
- : this.findLatestVersion(name, namespace);
148
-
151
+ unregister(name: string, namespace: string = 'Global', version?: string, contentHash?: string): boolean {
152
+ const id = this.resolveLookupKey(name, namespace, version, contentHash);
149
153
  if (!id) return false;
150
-
151
154
  return this.registry.delete(id);
152
155
  }
153
156
 
@@ -222,12 +225,10 @@ export class ComponentRegistry {
222
225
  * @param name - Component name
223
226
  * @param namespace - Component namespace
224
227
  * @param version - Component version
228
+ * @param contentHash - Optional content fingerprint (see {@link get})
225
229
  */
226
- release(name: string, namespace: string = 'Global', version?: string): void {
227
- const id = version
228
- ? this.generateRegistryKey(name, namespace, version)
229
- : this.findLatestVersion(name, namespace);
230
-
230
+ release(name: string, namespace: string = 'Global', version?: string, contentHash?: string): void {
231
+ const id = this.resolveLookupKey(name, namespace, version, contentHash);
231
232
  if (!id) return;
232
233
 
233
234
  const entry = this.registry.get(id);
@@ -352,17 +353,55 @@ export class ComponentRegistry {
352
353
  }
353
354
 
354
355
  /**
355
- * Generates a unique registry key
356
- * @param name - Component name
357
- * @param namespace - Component namespace
358
- * @param version - Component version
359
- * @returns Registry key
356
+ * Generates a unique registry key. When `contentHash` is supplied, it is
357
+ * appended so two specs that share `(name, namespace, version)` but have
358
+ * different code body don't collide.
359
+ */
360
+ private generateRegistryKey(name: string, namespace: string, version: string, contentHash?: string): string {
361
+ const base = this.config.enableNamespaces ? `${namespace}::${name}@${version}` : `${name}@${version}`;
362
+ return contentHash ? `${base}#${contentHash}` : base;
363
+ }
364
+
365
+ /**
366
+ * Resolves a lookup to an exact internal key. Centralizes the four
367
+ * lookup-shape variants used by `get`, `has`, `unregister`, and `release`:
368
+ *
369
+ * - hash + version → exact hash-suffixed key
370
+ * - version only → latest entry matching `(name, namespace, version)` across all hashes
371
+ * - hash only → no version → fall back to latest version (hash isn't enough alone)
372
+ * - neither → latest entry matching `(name, namespace)`
373
+ */
374
+ private resolveLookupKey(name: string, namespace: string, version?: string, contentHash?: string): string | undefined {
375
+ if (version && contentHash) {
376
+ return this.generateRegistryKey(name, namespace, version, contentHash);
377
+ }
378
+ if (version) {
379
+ return this.findLatestForVersion(name, namespace, version);
380
+ }
381
+ return this.findLatestVersion(name, namespace);
382
+ }
383
+
384
+ /**
385
+ * Find the most recently registered entry whose metadata matches
386
+ * `(name, namespace, version)`. Different content hashes for the same
387
+ * version are scanned and the newest by `registeredAt` wins.
360
388
  */
361
- private generateRegistryKey(name: string, namespace: string, version: string): string {
362
- if (this.config.enableNamespaces) {
363
- return `${namespace}::${name}@${version}`;
389
+ private findLatestForVersion(name: string, namespace: string, version: string): string | undefined {
390
+ let latestKey: string | undefined;
391
+ let latestDate: Date | undefined;
392
+
393
+ for (const [key, entry] of this.registry) {
394
+ if (entry.metadata.name === name &&
395
+ entry.metadata.namespace === namespace &&
396
+ entry.metadata.version === version) {
397
+ if (!latestDate || entry.metadata.registeredAt > latestDate) {
398
+ latestDate = entry.metadata.registeredAt;
399
+ latestKey = key;
400
+ }
401
+ }
364
402
  }
365
- return `${name}@${version}`;
403
+
404
+ return latestKey;
366
405
  }
367
406
 
368
407
  /**
@@ -376,7 +415,7 @@ export class ComponentRegistry {
376
415
  let latestDate: Date | undefined;
377
416
 
378
417
  for (const [key, entry] of this.registry) {
379
- if (entry.metadata.name === name &&
418
+ if (entry.metadata.name === name &&
380
419
  entry.metadata.namespace === namespace) {
381
420
  if (!latestDate || entry.metadata.registeredAt > latestDate) {
382
421
  latestDate = entry.metadata.registeredAt;