diff --git a/.changeset/fix-runtime-register-shared-options.md b/.changeset/fix-runtime-register-shared-options.md new file mode 100644 index 00000000000..f436e28ec8c --- /dev/null +++ b/.changeset/fix-runtime-register-shared-options.md @@ -0,0 +1,5 @@ +--- +"@module-federation/runtime-core": patch +--- + +Keep runtime-registered shared options available to `loadShare()` and `initializeSharing()` without mutating user configuration. diff --git a/packages/runtime-core/__tests__/instance.spec.ts b/packages/runtime-core/__tests__/instance.spec.ts index 9ccaf33bf56..e373d438c21 100644 --- a/packages/runtime-core/__tests__/instance.spec.ts +++ b/packages/runtime-core/__tests__/instance.spec.ts @@ -66,4 +66,117 @@ describe('ModuleFederation', () => { expect(module.initing).toBe(false); expect((module as any).initPromise).toBeUndefined(); }); + + it('registers dynamic shared modules without mutating options', async () => { + const GM = new ModuleFederation({ + name: '@federation/dynamic-shared', + remotes: [], + shared: {}, + }); + const sharedFactory = () => ({ name: 'dynamic-shared' }); + + GM.registerShared({ + 'dynamic-shared': { + version: '1.0.0', + get: () => Promise.resolve(sharedFactory), + }, + }); + + expect(GM.options.shared).toEqual({}); + expect(GM.shareScopeMap.default['dynamic-shared']['1.0.0']).toBeDefined(); + + GM.initOptions({ + name: '@federation/dynamic-shared', + remotes: [], + }); + + expect(GM.options.shared).toEqual({}); + + GM.initShareScopeMap('default', {}); + GM.initializeSharing(); + + expect(GM.shareScopeMap.default['dynamic-shared']['1.0.0']).toBeDefined(); + const loadedShared = await GM.loadShare<{ name: string }>('dynamic-shared'); + + expect(loadedShared).toBe(sharedFactory); + expect(loadedShared?.()).toEqual({ name: 'dynamic-shared' }); + }); + + it('does not mutate configured shared options when registering dynamically', () => { + const existingFactory = () => ({ name: 'existing-shared' }); + const GM = new ModuleFederation({ + name: '@federation/dynamic-shared-config', + remotes: [], + shared: { + 'existing-shared': { + version: '1.0.0', + scope: ['default', 'legacy'], + lib: existingFactory, + shareConfig: { + singleton: true, + requiredVersion: '^1.0.0', + eager: true, + strictVersion: true, + }, + }, + }, + }); + + GM.registerShared({ + 'existing-shared': { + version: '2.0.0', + scope: ['default', 'custom'], + get: () => Promise.resolve(() => ({ name: 'existing-shared' })), + shareConfig: { + singleton: true, + requiredVersion: '^2.0.0', + eager: true, + strictVersion: true, + }, + }, + }); + + expect(GM.options.shared['existing-shared']).toHaveLength(1); + expect(GM.options.shared['existing-shared'][0]).toMatchObject({ + version: '1.0.0', + scope: ['default', 'legacy'], + lib: existingFactory, + shareConfig: { + singleton: true, + requiredVersion: '^1.0.0', + eager: true, + strictVersion: true, + }, + }); + expect(GM.shareScopeMap.custom['existing-shared']['2.0.0']).toBeDefined(); + }); + + it('preserves array shared options and re-registration semantics', () => { + const GM = new ModuleFederation({ + name: '@federation/dynamic-array-shared', + remotes: [], + shared: {}, + }); + const shared = { + 'array-shared': [ + { + version: '1.0.0', + get: () => Promise.resolve(() => ({ version: '1.0.0' })), + }, + { + version: '2.0.0', + get: () => Promise.resolve(() => ({ version: '2.0.0' })), + }, + ], + } as const; + + GM.registerShared(shared); + expect(GM.options.shared).toEqual({}); + GM.initializeSharing(); + GM.registerShared(shared); + + expect(GM.shareScopeMap.default['array-shared']['1.0.0']).toBeDefined(); + expect(GM.shareScopeMap.default['array-shared']['2.0.0']).toBeDefined(); + expect(GM.options.shared).toEqual({}); + }); }); diff --git a/packages/runtime-core/src/shared/index.ts b/packages/runtime-core/src/shared/index.ts index 81a01847e41..1bccbd52317 100644 --- a/packages/runtime-core/src/shared/index.ts +++ b/packages/runtime-core/src/shared/index.ts @@ -27,6 +27,7 @@ import { } from '../utils/hooks'; import { formatShareConfigs, + mergeShareInfos, getRegisteredShare, getTargetSharedOptions, getGlobalShareScope, @@ -48,6 +49,7 @@ import { createRemoteEntryInitOptions } from '../module'; export class SharedHandler { host: ModuleFederation; shareScopeMap: ShareScopeMap; + private shareInfos: ShareInfos; hooks = new PluginSystem({ beforeRegisterShare: new SyncWaterfallHook<{ pkgName: string; @@ -114,6 +116,7 @@ export class SharedHandler { constructor(host: ModuleFederation) { this.host = host; this.shareScopeMap = {}; + this.shareInfos = {}; this.initTokens = {}; this._setGlobalShareScopeMap(host.options); } @@ -134,7 +137,7 @@ export class SharedHandler { pkgName, shareInfo, selectedShared, - shared: this.host.options.shared, + shared: this.shareInfos, shareScopeMap: this.shareScopeMap, lifecycle, origin: this.host, @@ -161,7 +164,7 @@ export class SharedHandler { this.hooks.lifecycle.errorLoadShare.emit({ pkgName, shareInfo, - shared: this.host.options.shared, + shared: this.shareInfos, shareScopeMap: this.shareScopeMap, lifecycle, origin: this.host, @@ -179,6 +182,7 @@ export class SharedHandler { globalOptions, userOptions, ); + this.shareInfos = mergeShareInfos(this.shareInfos, allShareInfos); const sharedKeys = Object.keys(newShareInfos); sharedKeys.forEach((sharedKey) => { @@ -227,7 +231,7 @@ export class SharedHandler { const shareOptions = getTargetSharedOptions({ pkgName, extraOptions, - shareInfos: host.options.shared, + shareInfos: this.shareInfos, }); let shareOptionsRes: Shared | undefined = shareOptions; @@ -247,7 +251,7 @@ export class SharedHandler { const loadShareRes = await this.hooks.lifecycle.beforeLoadShare.emit({ pkgName, shareInfo: shareOptions, - shared: host.options.shared, + shared: this.shareInfos, origin: host, }); @@ -477,8 +481,8 @@ export class SharedHandler { } } }; - Object.keys(host.options.shared).forEach((shareName) => { - const sharedArr = host.options.shared[shareName]; + Object.keys(this.shareInfos).forEach((shareName) => { + const sharedArr = this.shareInfos[shareName]; sharedArr.forEach((shared) => { if (shared.scope.includes(shareScopeName)) { register(shareName, shared); @@ -516,7 +520,7 @@ export class SharedHandler { const shareOptions = getTargetSharedOptions({ pkgName, extraOptions, - shareInfos: host.options.shared, + shareInfos: this.shareInfos, }); try { diff --git a/packages/runtime-core/src/utils/share.ts b/packages/runtime-core/src/utils/share.ts index 98bf0864e34..9444efe1de4 100644 --- a/packages/runtime-core/src/utils/share.ts +++ b/packages/runtime-core/src/utils/share.ts @@ -77,6 +77,33 @@ function formatShare( }; } +export function mergeShareInfos( + previous: ShareInfos, + next: ShareInfos, +): ShareInfos { + const merged = Object.keys(previous).reduce((res, shareKey) => { + res[shareKey] = [...previous[shareKey]]; + return res; + }, {} as ShareInfos); + + Object.keys(next).forEach((shareKey) => { + if (!merged[shareKey]) { + merged[shareKey] = [...next[shareKey]]; + return; + } + + const versions = new Set(merged[shareKey].map((shared) => shared.version)); + next[shareKey].forEach((shared) => { + if (!versions.has(shared.version)) { + versions.add(shared.version); + merged[shareKey].push(shared); + } + }); + }); + + return merged; +} + export function formatShareConfigs( prevOptions: Options, newOptions: UserOptions, @@ -95,24 +122,7 @@ export function formatShareConfigs( return res; }, {} as ShareInfos); - const allShareInfos = { - ...prevOptions.shared, - }; - - Object.keys(newShareInfos).forEach((shareKey) => { - if (!allShareInfos[shareKey]) { - allShareInfos[shareKey] = newShareInfos[shareKey]; - } else { - newShareInfos[shareKey].forEach((newUserSharedOptions) => { - const isSameVersion = allShareInfos[shareKey].find( - (sharedVal) => sharedVal.version === newUserSharedOptions.version, - ); - if (!isSameVersion) { - allShareInfos[shareKey].push(newUserSharedOptions); - } - }); - } - }); + const allShareInfos = mergeShareInfos(prevOptions.shared, newShareInfos); return { allShareInfos, newShareInfos }; }