Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/analyze-shared-version-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@module-federation/runtime-core': patch
---

Select the highest compatible shared version for version-first fallback without changing loaded-first selection.
69 changes: 44 additions & 25 deletions packages/runtime-core/src/utils/share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand All @@ -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;
}
Expand Down Expand Up @@ -424,37 +429,51 @@ export function getRegisteredShare(
}

const _usedTreeShaking = shouldUseTreeShaking(treeShaking);
if (_usedTreeShaking) {
for (const [versionKey, versionValue] of Object.entries(
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],
)) {
if (
!shouldUseTreeShaking(
versionValue.treeShaking,
undefined,
versionFilter,
);
};

if (_usedTreeShaking) {
const treeShakingVersion = findCompatibleVersion(
(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 = findCompatibleVersion((version) =>
satisfy(version, requiredVersion),
);
if (compatibleVersion) {
return {
shared: localShareScopeMap[sc][pkgName][compatibleVersion],
useTreesShaking: false,
};
}
}
return;
};
Expand Down
91 changes: 91 additions & 0 deletions packages/runtime/__tests__/get-registered-share.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,71 @@
import { describe, it, expect } from '@rstest/core';
import { assert, getRegisteredShare } from '@module-federation/runtime-core';
import { TreeShakingStatus } from '@module-federation/sdk';

type ResolveShare = Parameters<typeof getRegisteredShare>[3];
type ResolveShareParams = Parameters<ResolveShare['emit']>[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) => {
Expand Down Expand Up @@ -254,4 +318,31 @@ describe('get expected shared', () => {
assert(registeredShared, 'must get registeredShared');
expect(registeredShared.from).toEqual('remote');
});

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'],
];

orders.forEach((versions) => {
expect(getSelectedVersion(versions)).toEqual('1.9.0');
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');
});
});
Original file line number Diff line number Diff line change
@@ -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());
},
};
Original file line number Diff line number Diff line change
@@ -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());
},
};
Original file line number Diff line number Diff line change
@@ -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());
},
};
93 changes: 93 additions & 0 deletions packages/runtime/__tests__/shares.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,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 = {
Expand Down