From 5710b5e94fefd71229088a6e26844247689aa615 Mon Sep 17 00:00:00 2001 From: Baurin Vladislav Date: Fri, 7 Aug 2026 06:59:38 +0900 Subject: [PATCH] fix(webpack-bundler-runtime): preserve share scopes on repeated container init resolveShareScope now 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, providing defense-in-depth against incomplete re-initialization data. Fixes #4691 --- .../preserve-share-scopes-repeated-init.md | 6 + packages/runtime-core/src/shared/disabled.ts | 5 +- packages/runtime-core/src/shared/index.ts | 11 +- packages/runtime/__tests__/shares.spec.ts | 64 ++++++++++ .../initContainerEntry.array.spec.ts | 112 +++++++++++++++++- .../initContainerEntry.non-array.spec.ts | 104 ++++++++++++++++ .../src/initContainerEntry.ts | 102 +++++++++++----- 7 files changed, 370 insertions(+), 34 deletions(-) create mode 100644 .changeset/preserve-share-scopes-repeated-init.md 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, );