diff --git a/.changeset/preserve-share-scopes-repeated-init.md b/.changeset/preserve-share-scopes-repeated-init.md new file mode 100644 index 00000000000..4bb9a2e6e76 --- /dev/null +++ b/.changeset/preserve-share-scopes-repeated-init.md @@ -0,0 +1,6 @@ +--- +'@module-federation/webpack-bundler-runtime': patch +'@module-federation/runtime-core': patch +--- + +Preserve non-default share scopes on repeated container init. `resolveShareScope` falls back to the federation instance's existing `shareScopeMap` when the incoming host scope is missing or empty. `initShareScopeMap` merges incoming scopes with existing ones instead of replacing them to provide defense-in-depth against incomplete re-initialization data. diff --git a/packages/runtime-core/src/shared/disabled.ts b/packages/runtime-core/src/shared/disabled.ts index fb775abd056..02c945b3340 100644 --- a/packages/runtime-core/src/shared/disabled.ts +++ b/packages/runtime-core/src/shared/disabled.ts @@ -35,6 +35,9 @@ export class DisabledSharedHandler { scopeName: string, shareScope: ShareScopeMap[string], ): void { - this.shareScopeMap[scopeName] = shareScope; + this.shareScopeMap[scopeName] = { + ...this.shareScopeMap[scopeName], + ...shareScope, + }; } } diff --git a/packages/runtime-core/src/shared/index.ts b/packages/runtime-core/src/shared/index.ts index 81a01847e41..02858b2455d 100644 --- a/packages/runtime-core/src/shared/index.ts +++ b/packages/runtime-core/src/shared/index.ts @@ -650,7 +650,16 @@ export class SharedHandler { extraOptions: { hostShareScopeMap?: ShareScopeMap } = {}, ): void { const { host } = this; - this.shareScopeMap[scopeName] = shareScope; + // Merge at scope level: preserves shared packages that were registered + // by previous init calls or by setShared() for this scope. A repeated + // container init with an empty or incomplete shareScope will no longer + // wipe out shared entries that were already resolved. When the incoming + // scope includes a package already present, the incoming version set + // replaces the existing one (the remote's view is authoritative). + this.shareScopeMap[scopeName] = { + ...this.shareScopeMap[scopeName], + ...shareScope, + }; this.hooks.lifecycle.initContainerShareScopeMap.emit({ shareScope, options: host.options, diff --git a/packages/runtime/__tests__/shares.spec.ts b/packages/runtime/__tests__/shares.spec.ts index 35dd0bdba2e..41618b0665a 100644 --- a/packages/runtime/__tests__/shares.spec.ts +++ b/packages/runtime/__tests__/shares.spec.ts @@ -948,4 +948,68 @@ describe('load share while shared has multiple versions', () => { assert(sharedRes, "sharedRes can't be null"); expect(sharedRes.version).toEqual('16.0.0'); }); + + it('should preserve existing shared packages on repeated initShareScopeMap with partial scope', () => { + const FM1 = new ModuleFederation({ + name: '@shared/init-scope-merge', + shared: { + react: { singleton: true, version: '18.2.0' }, + '@tanstack/react-query': { singleton: true, version: '5.0.0' }, + }, + }); + FM1.registerPlugins(); + + FM1.initShareScopeMap('default', { + react: { + '18.2.0': { + scope: ['default'], + version: '18.2.0', + from: '@shared/init-scope-merge', + loaded: true, + }, + }, + }); + + expect(FM1.shareScopeMap['default']['react']).toBeDefined(); + + FM1.initShareScopeMap('default', { + '@tanstack/react-query': { + '5.0.0': { + scope: ['default'], + version: '5.0.0', + from: '@shared/init-scope-merge', + loaded: true, + }, + }, + }); + + // Both packages should exist after merge + expect(FM1.shareScopeMap['default']['react']).toBeDefined(); + expect(FM1.shareScopeMap['default']['@tanstack/react-query']).toBeDefined(); + }); + + it('should preserve existing shared packages on repeated initShareScopeMap with empty scope', () => { + const FM1 = new ModuleFederation({ + name: '@shared/init-scope-merge-empty', + shared: { + react: { singleton: true, version: '18.2.0' }, + }, + }); + FM1.registerPlugins(); + + FM1.initShareScopeMap('default', { + react: { + '18.2.0': { + scope: ['default'], + version: '18.2.0', + from: '@shared/init-scope-merge-empty', + loaded: true, + }, + }, + }); + + FM1.initShareScopeMap('default', {}); + + expect(FM1.shareScopeMap['default']['react']).toBeDefined(); + }); }); diff --git a/packages/webpack-bundler-runtime/__tests__/initContainerEntry.array.spec.ts b/packages/webpack-bundler-runtime/__tests__/initContainerEntry.array.spec.ts index 2e48d71953e..3fad22d774d 100644 --- a/packages/webpack-bundler-runtime/__tests__/initContainerEntry.array.spec.ts +++ b/packages/webpack-bundler-runtime/__tests__/initContainerEntry.array.spec.ts @@ -118,7 +118,7 @@ describe('initContainerEntry with array-based share scopes', () => { }); // Execute - const result = initContainerEntry(mockOptions); + initContainerEntry(mockOptions); // Verify expect( @@ -431,7 +431,7 @@ describe('initContainerEntry with array-based share scopes', () => { test('should behave differently for proxyInitializeSharing=false vs true with array shareScopeKey', () => { // Mock setup for shared=false (proxyInitializeSharing=false) - const mockIFunctionFalse = jest.fn().mockImplementation((key) => { + const mockIFunctionFalse = jest.fn().mockImplementation((_key) => { return Promise.resolve(true); }); @@ -491,7 +491,7 @@ describe('initContainerEntry with array-based share scopes', () => { test('should handle proxyInitializeSharing=false with array shareScopeKey', async () => { // Setup with shared: false (making proxyInitializeSharing false) - const mockIFunction = jest.fn().mockImplementation((key) => { + const mockIFunction = jest.fn().mockImplementation((_key) => { return Promise.resolve(true); }); @@ -1100,4 +1100,110 @@ describe('initContainerEntry with array-based share scopes', () => { expect(mockIFunction).toHaveBeenCalledWith('key3', ['test-scope']); expect(mockIFunction).toHaveBeenCalledTimes(3); }); + + test('should preserve non-default share scopes across repeated init with incomplete host shareScopeMap', () => { + const defaultScope = { + react: { + '18.2.0': { + scope: ['default'], + }, + }, + }; + const customScope = { + '@tanstack/react-query': { + '5.0.0': { + scope: ['custom'], + }, + }, + }; + const shareScopeMap: Record> = {}; + const federationInstance = createMockFederationInstance({ + shareScopeMap, + initShareScopeMap: jest.fn( + (scopeName: string, scope: Record) => { + shareScopeMap[scopeName] = { + ...shareScopeMap[scopeName], + ...scope, + }; + }, + ), + }); + const webpackRequire = createMockWebpackRequire({ + I: jest.fn().mockReturnValue(Promise.resolve(true)), + federation: createMockFederation({ + instance: federationInstance, + initOptions: { + name: 'test-app', + shared: false, + }, + }), + }); + + initContainerEntry( + createMockOptions({ + webpackRequire, + shareScopeKey: ['default', 'custom'], + shareScope: defaultScope, + remoteEntryInitOptions: createMockRemoteEntryInitOptions({ + shareScopeKeys: ['default', 'custom'], + shareScopeMap: { + default: defaultScope, + custom: customScope, + }, + }), + }), + ); + + expect(federationInstance.shareScopeMap.custom).toEqual(customScope); + + // Isolation: default must not be polluted with custom-scope-only packages + expect(Object.keys(federationInstance.shareScopeMap.default)).toContain( + 'react', + ); + expect(Object.keys(federationInstance.shareScopeMap.default)).not.toContain( + '@tanstack/react-query', + ); + + const emptyCustomScopeInitOptions = createMockRemoteEntryInitOptions({ + shareScopeKeys: ['default', 'custom'], + shareScopeMap: { + default: defaultScope, + custom: {}, + }, + }); + + initContainerEntry( + createMockOptions({ + webpackRequire, + shareScopeKey: ['default', 'custom'], + shareScope: defaultScope, + remoteEntryInitOptions: emptyCustomScopeInitOptions, + }), + ); + + expect(federationInstance.shareScopeMap.custom).toEqual(customScope); + + const missingCustomScopeInitOptions = createMockRemoteEntryInitOptions({ + shareScopeKeys: ['default', 'custom'], + shareScopeMap: { + default: defaultScope, + }, + }); + + initContainerEntry( + createMockOptions({ + webpackRequire, + shareScopeKey: ['default', 'custom'], + shareScope: defaultScope, + remoteEntryInitOptions: missingCustomScopeInitOptions, + }), + ); + + expect(federationInstance.shareScopeMap.custom).toEqual(customScope); + + // After missing custom re-init, default scope remains clean + expect(Object.keys(federationInstance.shareScopeMap.default)).not.toContain( + '@tanstack/react-query', + ); + }); }); diff --git a/packages/webpack-bundler-runtime/__tests__/initContainerEntry.non-array.spec.ts b/packages/webpack-bundler-runtime/__tests__/initContainerEntry.non-array.spec.ts index b9cf5987dc3..9ef555461ed 100644 --- a/packages/webpack-bundler-runtime/__tests__/initContainerEntry.non-array.spec.ts +++ b/packages/webpack-bundler-runtime/__tests__/initContainerEntry.non-array.spec.ts @@ -690,4 +690,108 @@ describe('initContainerEntry with non-array-based share scopes', () => { mockOptions.webpackRequire.federation.instance?.initShareScopeMap, ).toHaveBeenCalledWith('key2', expect.anything(), expect.anything()); }); + + test('should preserve non-default share scopes across repeated init with string shareScopeKey and array hostShareScopeKeys', () => { + const defaultScope = { + react: { + '18.2.0': { + scope: ['default'], + }, + }, + }; + const customScope = { + '@tanstack/react-query': { + '5.0.0': { + scope: ['custom'], + }, + }, + }; + const shareScopeMap: Record> = {}; + const federationInstance = createMockFederationInstance({ + shareScopeMap, + initShareScopeMap: jest.fn( + (scopeName: string, scope: Record) => { + shareScopeMap[scopeName] = { + ...shareScopeMap[scopeName], + ...scope, + }; + }, + ), + options: { shared: false }, + }); + const webpackRequire = createMockWebpackRequire({ + I: jest.fn().mockReturnValue(Promise.resolve(true)), + federation: createMockFederation({ + instance: federationInstance, + initOptions: { + name: 'test-app', + shared: false, + }, + }), + }); + + const baseOptions = { + webpackRequire, + shareScopeKey: 'default', + shareScope: defaultScope, + }; + + initContainerEntry( + createMockOptions({ + ...baseOptions, + remoteEntryInitOptions: createMockRemoteEntryInitOptions({ + shareScopeKeys: ['default', 'custom'], + shareScopeMap: { + default: defaultScope, + custom: customScope, + }, + }), + }), + ); + + expect(federationInstance.shareScopeMap.custom).toEqual(customScope); + + // Isolation: default must not be polluted with custom-scope-only packages + expect(Object.keys(federationInstance.shareScopeMap.default)).toContain( + 'react', + ); + expect(Object.keys(federationInstance.shareScopeMap.default)).not.toContain( + '@tanstack/react-query', + ); + + // Re-init with empty custom scope + initContainerEntry( + createMockOptions({ + ...baseOptions, + remoteEntryInitOptions: createMockRemoteEntryInitOptions({ + shareScopeKeys: ['default', 'custom'], + shareScopeMap: { + default: defaultScope, + custom: {}, + }, + }), + }), + ); + + expect(federationInstance.shareScopeMap.custom).toEqual(customScope); + + // Re-init with missing custom scope + initContainerEntry( + createMockOptions({ + ...baseOptions, + remoteEntryInitOptions: createMockRemoteEntryInitOptions({ + shareScopeKeys: ['default', 'custom'], + shareScopeMap: { + default: defaultScope, + }, + }), + }), + ); + + expect(federationInstance.shareScopeMap.custom).toEqual(customScope); + + expect(Object.keys(federationInstance.shareScopeMap.default)).not.toContain( + '@tanstack/react-query', + ); + }); }); diff --git a/packages/webpack-bundler-runtime/src/initContainerEntry.ts b/packages/webpack-bundler-runtime/src/initContainerEntry.ts index 00884616439..e19306fc8b2 100644 --- a/packages/webpack-bundler-runtime/src/initContainerEntry.ts +++ b/packages/webpack-bundler-runtime/src/initContainerEntry.ts @@ -23,56 +23,100 @@ export function initContainerEntry( federationInstance.initOptions({ name: webpackRequire.federation.initOptions.name, remotes: [], + shared: federationInstance.options?.shared, ...remoteEntryInitOptions, }); const hostShareScopeKeys = remoteEntryInitOptions?.shareScopeKeys; - const hostShareScopeMap = remoteEntryInitOptions?.shareScopeMap; + const hostShareScopeMap = remoteEntryInitOptions?.shareScopeMap || {}; + const existingShareScopeMap = federationInstance.shareScopeMap || {}; + + /** + * Resolve the share scope for a given key. + * Precedence: + * 1. Non-empty host scope from `hostShareScopeMap[key]` + * 2. Existing scope from `existingShareScopeMap[key]` + * (when host scope is missing or empty) + * 3. `fallbackShareScope` when host scope is empty + * and no existing scope — only when `fallbackWhenEmpty` is enabled + * 4. `fallbackShareScope` when neither host nor existing has the key + */ + const hasOwnScope = (scopeMap: Record, key: string) => + Object.prototype.hasOwnProperty.call(scopeMap, key); + const isEmptyShareScope = (scope: Record | undefined) => + !scope || !Object.keys(scope).length; + + const resolveShareScope = ( + key: string, + fallbackShareScope: Record, + options: { fallbackWhenEmpty?: boolean } = {}, + ) => { + const currentShareScope = hostShareScopeMap[key]; + + if ( + hasOwnScope(hostShareScopeMap, key) && + !isEmptyShareScope(currentShareScope) + ) { + return currentShareScope; + } + + if ( + hasOwnScope(existingShareScopeMap, key) && + (!hasOwnScope(hostShareScopeMap, key) || + isEmptyShareScope(currentShareScope)) + ) { + return existingShareScopeMap[key]; + } + + if (hasOwnScope(hostShareScopeMap, key)) { + return options.fallbackWhenEmpty && isEmptyShareScope(currentShareScope) + ? fallbackShareScope + : currentShareScope; + } + + return fallbackShareScope; + }; // host: 'default' remote: 'default' remote['default'] = hostShareScopeMap['default'] - // host: ['default', 'scope1'] remote: 'default' remote['default'] = hostShareScopeMap['default']; remote['scope1'] = hostShareScopeMap['scop1'] + // host: ['default', 'scope1'] remote: 'default' remote['default'] = hostShareScopeMap['default']; remote['scope1'] = hostShareScopeMap['scope1'] // host: 'default' remote: ['default','scope1'] remote['default'] = hostShareScopeMap['default']; remote['scope1'] = hostShareScopeMap['scope1'] = {} // host: ['scope1','default'] remote: ['scope1','scope2'] => remote['scope1'] = hostShareScopeMap['scope1']; remote['scope2'] = hostShareScopeMap['scope2'] = {}; if (!shareScopeKey || typeof shareScopeKey === 'string') { const key = shareScopeKey || 'default'; if (Array.isArray(hostShareScopeKeys)) { - // const sc = hostShareScopeMap![key]; - // if (!sc) { - // throw new Error('shareScopeKey is not exist in hostShareScopeMap'); - // } - // federationInstance.initShareScopeMap(key, sc, { - // hostShareScopeMap: remoteEntryInitOptions?.shareScopeMap || {}, - // }); - hostShareScopeKeys.forEach((hostKey) => { - if (!hostShareScopeMap![hostKey]) { - hostShareScopeMap![hostKey] = {}; + const sc = resolveShareScope(hostKey, {}); + if ( + !hasOwnScope(hostShareScopeMap, hostKey) || + (hasOwnScope(existingShareScopeMap, hostKey) && + isEmptyShareScope(hostShareScopeMap[hostKey])) + ) { + // Write back the resolved scope to fix missing or empty entries + // in the host map for subsequent iterations of this loop. + hostShareScopeMap[hostKey] = sc; } - const sc = hostShareScopeMap![hostKey]; federationInstance.initShareScopeMap(hostKey, sc, { - hostShareScopeMap: remoteEntryInitOptions?.shareScopeMap || {}, + hostShareScopeMap, }); }); } else { - federationInstance.initShareScopeMap(key, shareScope, { - hostShareScopeMap: remoteEntryInitOptions?.shareScopeMap || {}, + const sc = resolveShareScope(key, shareScope, { + fallbackWhenEmpty: true, + }); + federationInstance.initShareScopeMap(key, sc, { + hostShareScopeMap, }); } } else { + const hasHostShareInfo = + Boolean(hostShareScopeKeys) && + Boolean(remoteEntryInitOptions?.shareScopeMap); shareScopeKey.forEach((key) => { - if (!hostShareScopeKeys || !hostShareScopeMap) { - federationInstance.initShareScopeMap(key, shareScope, { - hostShareScopeMap: remoteEntryInitOptions?.shareScopeMap || {}, - }); - return; - } - - if (!hostShareScopeMap[key]) { - hostShareScopeMap[key] = {}; - } - const sc = hostShareScopeMap[key]; + const sc = hasHostShareInfo + ? resolveShareScope(key, {}) + : resolveShareScope(key, shareScope, { fallbackWhenEmpty: true }); federationInstance.initShareScopeMap(key, sc, { - hostShareScopeMap: remoteEntryInitOptions?.shareScopeMap || {}, + hostShareScopeMap, }); }); } @@ -86,7 +130,7 @@ export function initContainerEntry( return webpackRequire.I(shareScopeKey || 'default', initScope); } - var proxyInitializeSharing = Boolean( + const proxyInitializeSharing = Boolean( webpackRequire.federation.initOptions.shared, );