From c7016d5a67b9abb4988fb47e9694462bc280e52d Mon Sep 17 00:00:00 2001 From: "dm.choi" Date: Wed, 5 Aug 2026 00:24:43 +0900 Subject: [PATCH 1/2] fix(runtime-core): select highest compatible shared version --- .../analyze-shared-version-selection.md | 5 ++ packages/runtime-core/src/utils/share.ts | 53 ++++++++------- .../__tests__/get-registered-share.spec.ts | 65 +++++++++++++++++++ 3 files changed, 99 insertions(+), 24 deletions(-) create mode 100644 .changeset/analyze-shared-version-selection.md diff --git a/.changeset/analyze-shared-version-selection.md b/.changeset/analyze-shared-version-selection.md new file mode 100644 index 00000000000..bd418707557 --- /dev/null +++ b/.changeset/analyze-shared-version-selection.md @@ -0,0 +1,5 @@ +--- +'@module-federation/runtime-core': patch +--- + +Select the highest compatible shared version when falling back from an incompatible version. diff --git a/packages/runtime-core/src/utils/share.ts b/packages/runtime-core/src/utils/share.ts index 98bf0864e34..77660b885a2 100644 --- a/packages/runtime-core/src/utils/share.ts +++ b/packages/runtime-core/src/utils/share.ts @@ -168,6 +168,7 @@ export function versionLt(a: string, b: string): boolean { export const findVersion = ( shareVersionMap: ShareScopeMap[string][string], cb?: (prev: string, cur: string) => boolean, + versionFilter?: (version: string) => boolean, ): string => { const callback = cb || @@ -176,6 +177,10 @@ export const findVersion = ( }; return Object.keys(shareVersionMap).reduce((prev: number | string, cur) => { + if (versionFilter && !versionFilter(cur)) { + return prev; + } + if (!prev) { return cur; } @@ -425,36 +430,36 @@ export function getRegisteredShare( const _usedTreeShaking = shouldUseTreeShaking(treeShaking); if (_usedTreeShaking) { - for (const [versionKey, versionValue] of Object.entries( + const treeShakingVersion = findVersion( localShareScopeMap[sc][pkgName], - )) { - if ( - !shouldUseTreeShaking( - versionValue.treeShaking, + undefined, + (version) => + satisfy(version, requiredVersion) && + shouldUseTreeShaking( + localShareScopeMap[sc][pkgName][version].treeShaking, treeShaking?.usedExports, - ) - ) { - continue; - } - - if (satisfy(versionKey, requiredVersion)) { - return { - shared: versionValue, - useTreesShaking: _usedTreeShaking, - }; - } - } - } - for (const [versionKey, versionValue] of Object.entries( - localShareScopeMap[sc][pkgName], - )) { - if (satisfy(versionKey, requiredVersion)) { + ), + ); + + if (treeShakingVersion) { return { - shared: versionValue, - useTreesShaking: false, + shared: localShareScopeMap[sc][pkgName][treeShakingVersion], + useTreesShaking: _usedTreeShaking, }; } } + + const compatibleVersion = findVersion( + localShareScopeMap[sc][pkgName], + undefined, + (version) => satisfy(version, requiredVersion), + ); + if (compatibleVersion) { + return { + shared: localShareScopeMap[sc][pkgName][compatibleVersion], + useTreesShaking: false, + }; + } } return; }; diff --git a/packages/runtime/__tests__/get-registered-share.spec.ts b/packages/runtime/__tests__/get-registered-share.spec.ts index a1a154470af..14f1ecdafbe 100644 --- a/packages/runtime/__tests__/get-registered-share.spec.ts +++ b/packages/runtime/__tests__/get-registered-share.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from '@rstest/core'; import { assert, getRegisteredShare } from '@module-federation/runtime-core'; +import { TreeShakingStatus } from '@module-federation/sdk'; describe('get expected shared', () => { it('get loading shared if sharedStrategy is "loaded-first"', () => { @@ -254,4 +255,68 @@ describe('get expected shared', () => { assert(registeredShared, 'must get registeredShared'); expect(registeredShared.from).toEqual('remote'); }); + + it('gets the highest compatible non-singleton shared version regardless of registration order', () => { + const createShared = (version: string, treeShaking = false) => ({ + deps: [], + useIn: [], + from: `v${version}`, + get: () => { + //noop + }, + loaded: false, + loading: null, + version, + scope: ['default'], + shareConfig: { + requiredVersion: '^1.0.0', + singleton: false, + eager: false, + strictVersion: false, + }, + strategy: 'version-first' as const, + ...(treeShaking + ? { + treeShaking: { + status: TreeShakingStatus.CALCULATED, + }, + } + : {}), + }); + + const getSelectedVersion = (versions: string[], treeShaking = false) => { + const { shared } = + getRegisteredShare( + // @ts-ignore + { + default: { + example: Object.fromEntries( + versions.map((version) => [ + version, + createShared(version, treeShaking), + ]), + ), + }, + }, + 'example', + createShared('consumer', treeShaking), + { + emit: () => undefined, + }, + ) || {}; + + assert(shared, 'must get registered shared'); + return shared.version; + }; + + const orders = [ + ['1.0.0', '1.9.0', '2.0.0'], + ['1.9.0', '1.0.0', '2.0.0'], + ]; + + orders.forEach((versions) => { + expect(getSelectedVersion(versions)).toEqual('1.9.0'); + expect(getSelectedVersion(versions, true)).toEqual('1.9.0'); + }); + }); }); From b08ccfd87ef1666a952e8df16eda56849e516b18 Mon Sep 17 00:00:00 2001 From: "dm.choi" Date: Tue, 18 Aug 2026 23:02:07 +0900 Subject: [PATCH 2/2] fix(runtime-core): scope highest-compatible fallback to version-first --- .../analyze-shared-version-selection.md | 2 +- packages/runtime-core/src/utils/share.ts | 26 +++- .../__tests__/get-registered-share.spec.ts | 132 +++++++++++------- .../remote-1-0/federation-remote-entry.js | 30 ++++ .../remote-1-9/federation-remote-entry.js | 30 ++++ .../remote-2-0/federation-remote-entry.js | 30 ++++ packages/runtime/__tests__/shares.spec.ts | 93 ++++++++++++ 7 files changed, 283 insertions(+), 60 deletions(-) create mode 100644 packages/runtime/__tests__/resources/shared-version-selection/remote-1-0/federation-remote-entry.js create mode 100644 packages/runtime/__tests__/resources/shared-version-selection/remote-1-9/federation-remote-entry.js create mode 100644 packages/runtime/__tests__/resources/shared-version-selection/remote-2-0/federation-remote-entry.js diff --git a/.changeset/analyze-shared-version-selection.md b/.changeset/analyze-shared-version-selection.md index bd418707557..ff8ff9240c7 100644 --- a/.changeset/analyze-shared-version-selection.md +++ b/.changeset/analyze-shared-version-selection.md @@ -2,4 +2,4 @@ '@module-federation/runtime-core': patch --- -Select the highest compatible shared version when falling back from an incompatible version. +Select the highest compatible shared version for version-first fallback without changing loaded-first selection. diff --git a/packages/runtime-core/src/utils/share.ts b/packages/runtime-core/src/utils/share.ts index 77660b885a2..884c08144ac 100644 --- a/packages/runtime-core/src/utils/share.ts +++ b/packages/runtime-core/src/utils/share.ts @@ -429,10 +429,26 @@ export function getRegisteredShare( } const _usedTreeShaking = shouldUseTreeShaking(treeShaking); - if (_usedTreeShaking) { - const treeShakingVersion = findVersion( + const findCompatibleVersion = ( + versionFilter: (version: string) => boolean, + ) => { + // loaded-first keeps its registration-order fallback after its + // initial loaded or loading version selection. + if (strategy === 'loaded-first') { + return Object.keys(localShareScopeMap[sc][pkgName]).find( + versionFilter, + ); + } + + return findVersion( localShareScopeMap[sc][pkgName], undefined, + versionFilter, + ); + }; + + if (_usedTreeShaking) { + const treeShakingVersion = findCompatibleVersion( (version) => satisfy(version, requiredVersion) && shouldUseTreeShaking( @@ -449,10 +465,8 @@ export function getRegisteredShare( } } - const compatibleVersion = findVersion( - localShareScopeMap[sc][pkgName], - undefined, - (version) => satisfy(version, requiredVersion), + const compatibleVersion = findCompatibleVersion((version) => + satisfy(version, requiredVersion), ); if (compatibleVersion) { return { diff --git a/packages/runtime/__tests__/get-registered-share.spec.ts b/packages/runtime/__tests__/get-registered-share.spec.ts index 14f1ecdafbe..9ca1fb3d2c0 100644 --- a/packages/runtime/__tests__/get-registered-share.spec.ts +++ b/packages/runtime/__tests__/get-registered-share.spec.ts @@ -2,7 +2,70 @@ import { describe, it, expect } from '@rstest/core'; import { assert, getRegisteredShare } from '@module-federation/runtime-core'; import { TreeShakingStatus } from '@module-federation/sdk'; +type ResolveShare = Parameters[3]; +type ResolveShareParams = Parameters[0]; + describe('get expected shared', () => { + const resolveShare = { + emit: (data: ResolveShareParams) => data, + } as ResolveShare; + + const createShared = ( + version: string, + treeShaking = false, + strategy: 'version-first' | 'loaded-first' = 'version-first', + ) => ({ + deps: [], + useIn: [], + from: `v${version}`, + get: () => () => ({}), + loaded: false, + loading: null, + version, + scope: ['default'], + shareConfig: { + requiredVersion: '^1.0.0', + singleton: false, + eager: false, + strictVersion: false, + }, + strategy, + ...(treeShaking + ? { + treeShaking: { + status: TreeShakingStatus.CALCULATED, + }, + } + : {}), + }); + + const getSelectedVersion = ( + versions: string[], + treeShaking = false, + strategy: 'version-first' | 'loaded-first' = 'version-first', + ) => { + const { shared } = + getRegisteredShare( + // @ts-ignore + { + default: { + example: Object.fromEntries( + versions.map((version) => [ + version, + createShared(version, treeShaking, strategy), + ]), + ), + }, + }, + 'example', + createShared('consumer', treeShaking, strategy), + resolveShare, + ) || {}; + + assert(shared, 'must get registered shared'); + return shared.version; + }; + it('get loading shared if sharedStrategy is "loaded-first"', () => { let res; const promise = new Promise((resolve) => { @@ -256,59 +319,7 @@ describe('get expected shared', () => { expect(registeredShared.from).toEqual('remote'); }); - it('gets the highest compatible non-singleton shared version regardless of registration order', () => { - const createShared = (version: string, treeShaking = false) => ({ - deps: [], - useIn: [], - from: `v${version}`, - get: () => { - //noop - }, - loaded: false, - loading: null, - version, - scope: ['default'], - shareConfig: { - requiredVersion: '^1.0.0', - singleton: false, - eager: false, - strictVersion: false, - }, - strategy: 'version-first' as const, - ...(treeShaking - ? { - treeShaking: { - status: TreeShakingStatus.CALCULATED, - }, - } - : {}), - }); - - const getSelectedVersion = (versions: string[], treeShaking = false) => { - const { shared } = - getRegisteredShare( - // @ts-ignore - { - default: { - example: Object.fromEntries( - versions.map((version) => [ - version, - createShared(version, treeShaking), - ]), - ), - }, - }, - 'example', - createShared('consumer', treeShaking), - { - emit: () => undefined, - }, - ) || {}; - - assert(shared, 'must get registered shared'); - return shared.version; - }; - + it('gets the highest compatible non-singleton shared version for version-first regardless of registration order', () => { const orders = [ ['1.0.0', '1.9.0', '2.0.0'], ['1.9.0', '1.0.0', '2.0.0'], @@ -319,4 +330,19 @@ describe('get expected shared', () => { expect(getSelectedVersion(versions, true)).toEqual('1.9.0'); }); }); + + it('preserves registration-order fallback for loaded-first', () => { + expect( + getSelectedVersion(['1.0.0', '1.9.0', '2.0.0'], false, 'loaded-first'), + ).toEqual('1.0.0'); + expect( + getSelectedVersion(['1.9.0', '1.0.0', '2.0.0'], false, 'loaded-first'), + ).toEqual('1.9.0'); + expect( + getSelectedVersion(['1.0.0', '1.9.0', '2.0.0'], true, 'loaded-first'), + ).toEqual('1.0.0'); + expect( + getSelectedVersion(['1.9.0', '1.0.0', '2.0.0'], true, 'loaded-first'), + ).toEqual('1.9.0'); + }); }); diff --git a/packages/runtime/__tests__/resources/shared-version-selection/remote-1-0/federation-remote-entry.js b/packages/runtime/__tests__/resources/shared-version-selection/remote-1-0/federation-remote-entry.js new file mode 100644 index 00000000000..d7c904b4e9f --- /dev/null +++ b/packages/runtime/__tests__/resources/shared-version-selection/remote-1-0/federation-remote-entry.js @@ -0,0 +1,30 @@ +globalThis['__FEDERATION_shared-version-selection-1_0__'] = { + get() { + return () => ({ + version: '1.0.0', + render: () => 'rendered with the 1.0.0 shared implementation', + }); + }, + init(shareScope) { + const instance = new globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__({ + name: '@shared-version-selection/remote-1-0', + version: '0.0.1', + shared: { + 'shared-version-selection': { + version: '1.0.0', + scope: 'default', + strategy: 'version-first', + get: () => () => ({ + version: '1.0.0', + render: () => 'rendered with the 1.0.0 shared implementation', + }), + }, + }, + }); + + globalThis.__FEDERATION__.__INSTANCES__.push(instance); + instance.initShareScopeMap('default', shareScope); + + return Promise.all(instance.initializeSharing()); + }, +}; diff --git a/packages/runtime/__tests__/resources/shared-version-selection/remote-1-9/federation-remote-entry.js b/packages/runtime/__tests__/resources/shared-version-selection/remote-1-9/federation-remote-entry.js new file mode 100644 index 00000000000..6a418cfbd3a --- /dev/null +++ b/packages/runtime/__tests__/resources/shared-version-selection/remote-1-9/federation-remote-entry.js @@ -0,0 +1,30 @@ +globalThis['__FEDERATION_shared-version-selection-1_9__'] = { + get() { + return () => ({ + version: '1.9.0', + render: () => 'rendered with the 1.9.0 shared implementation', + }); + }, + init(shareScope) { + const instance = new globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__({ + name: '@shared-version-selection/remote-1-9', + version: '0.0.1', + shared: { + 'shared-version-selection': { + version: '1.9.0', + scope: 'default', + strategy: 'version-first', + get: () => () => ({ + version: '1.9.0', + render: () => 'rendered with the 1.9.0 shared implementation', + }), + }, + }, + }); + + globalThis.__FEDERATION__.__INSTANCES__.push(instance); + instance.initShareScopeMap('default', shareScope); + + return Promise.all(instance.initializeSharing()); + }, +}; diff --git a/packages/runtime/__tests__/resources/shared-version-selection/remote-2-0/federation-remote-entry.js b/packages/runtime/__tests__/resources/shared-version-selection/remote-2-0/federation-remote-entry.js new file mode 100644 index 00000000000..c7ae468ac01 --- /dev/null +++ b/packages/runtime/__tests__/resources/shared-version-selection/remote-2-0/federation-remote-entry.js @@ -0,0 +1,30 @@ +globalThis['__FEDERATION_shared-version-selection-2_0__'] = { + get() { + return () => ({ + version: '2.0.0', + render: () => 'rendered with the 2.0.0 shared implementation', + }); + }, + init(shareScope) { + const instance = new globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__({ + name: '@shared-version-selection/remote-2-0', + version: '0.0.1', + shared: { + 'shared-version-selection': { + version: '2.0.0', + scope: 'default', + strategy: 'version-first', + get: () => () => ({ + version: '2.0.0', + render: () => 'rendered with the 2.0.0 shared implementation', + }), + }, + }, + }); + + globalThis.__FEDERATION__.__INSTANCES__.push(instance); + instance.initShareScopeMap('default', shareScope); + + return Promise.all(instance.initializeSharing()); + }, +}; diff --git a/packages/runtime/__tests__/shares.spec.ts b/packages/runtime/__tests__/shares.spec.ts index 35dd0bdba2e..5044afbff37 100644 --- a/packages/runtime/__tests__/shares.spec.ts +++ b/packages/runtime/__tests__/shares.spec.ts @@ -876,6 +876,99 @@ describe('load share with different strategy', () => { }); }); +describe('load share with host and remote version selection', () => { + const createHost = ( + strategy: 'version-first' | 'loaded-first' = 'version-first', + ) => + new ModuleFederation({ + name: '@shared-version-selection/host', + // Keep remote initialization enabled so the test can compare the + // fallback policy after all host/remote providers are registered. + shareStrategy: 'version-first', + remotes: [ + { + name: '__FEDERATION_shared-version-selection-1_0__', + alias: 'remote-1-0', + entry: + 'http://localhost:1111/resources/shared-version-selection/remote-1-0/federation-remote-entry.js', + }, + { + name: '__FEDERATION_shared-version-selection-1_9__', + alias: 'remote-1-9', + entry: + 'http://localhost:1111/resources/shared-version-selection/remote-1-9/federation-remote-entry.js', + }, + { + name: '__FEDERATION_shared-version-selection-2_0__', + alias: 'remote-2-0', + entry: + 'http://localhost:1111/resources/shared-version-selection/remote-2-0/federation-remote-entry.js', + }, + ], + shared: { + 'shared-version-selection': { + version: '1.0.0', + strategy, + shareConfig: { + requiredVersion: '^1.0.0', + singleton: false, + }, + get: () => () => ({ + version: '1.0.0', + render: () => 'rendered with the 1.0.0 shared implementation', + }), + }, + }, + }); + + it('selects the highest compatible version registered by host and remotes', async () => { + setGlobalFederationConstructor(ModuleFederation, true); + + const host = createHost(); + + const shared = await host.loadShare<{ + version: string; + render: () => string; + }>('shared-version-selection'); + + assert(shared, 'shared implementation must be available'); + const sharedImplementation = shared(); + assert(sharedImplementation, 'shared implementation must return a module'); + + // Both 1.0.0 and 1.9.0 satisfy ^1.0.0; version-first should choose the + // highest compatible implementation, whose behavior is observable here. + expect( + Object.keys(host.shareScopeMap.default['shared-version-selection']), + ).toEqual(['1.0.0', '1.9.0', '2.0.0']); + expect(sharedImplementation.version).toBe('1.9.0'); + expect(sharedImplementation.render()).toBe( + 'rendered with the 1.9.0 shared implementation', + ); + }); + + it('preserves registration-order fallback for loaded-first', async () => { + setGlobalFederationConstructor(ModuleFederation, true); + + const host = createHost('loaded-first'); + const shared = await host.loadShare<{ + version: string; + render: () => string; + }>('shared-version-selection'); + + assert(shared, 'shared implementation must be available'); + const sharedImplementation = shared(); + assert(sharedImplementation, 'shared implementation must return a module'); + + expect( + Object.keys(host.shareScopeMap.default['shared-version-selection']), + ).toEqual(['1.0.0', '1.9.0', '2.0.0']); + expect(sharedImplementation.version).toBe('1.0.0'); + expect(sharedImplementation.render()).toBe( + 'rendered with the 1.0.0 shared implementation', + ); + }); +}); + describe('load share while shared has multiple versions', () => { it('return loaded and has max version shared by default', async () => { const federationConfig1: UserOptions = {