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/quiet-remote-instance-cleanup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@module-federation/runtime-core': patch
---

removeRemote (run by registerRemotes({ force: true })) now resolves the remote's runtime instance in __FEDERATION__.__INSTANCES__ explicitly: registered name + buildVersion, then entryGlobalName + buildVersion, and only when no buildVersion is known the registered name or a unique entryGlobalName. A versioned lookup never falls back to a name-only match and an ambiguous match removes nothing. Share-scope entries are released by the instance's own name (options.name), never by the registration alias. A warning is logged only for ambiguous matches or when same-named instances exist with a different build version; plain containers without a runtime instance stay silent.
210 changes: 209 additions & 1 deletion packages/runtime-core/__tests__/register-remotes.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { assert, describe, it, expect, rs } from '@rstest/core';
import { ModuleFederation } from '../src/index';
import {
ModuleFederation,
CurrentGlobal,
Global,
setGlobalFederationInstance,
} from '../src/index';

describe('ModuleFederation', () => {
it('registers new remotes and loads them correctly', async () => {
Expand Down Expand Up @@ -102,6 +107,209 @@ describe('ModuleFederation', () => {
// Value is different from the registered remote
expect(newApp1Res).toBe('hello app1 entry2');
});
describe('removeRemote runtime instance ownership', () => {
const BUILD_NAME = '@register-remotes/app1';
const HOST_NAME = '@federation/instance';
const ENTRY1 =
'http://localhost:1111/resources/register-remotes/app1/federation-remote-entry.js';
const ENTRY2 =
'http://localhost:1111/resources/register-remotes/app1/federation-remote-entry2.js';
const remoteOf = (
entry: string,
registeredName: string,
entryGlobalName?: string,
) => ({
name: registeredName,
entry,
...(entryGlobalName ? { entryGlobalName } : {}),
});
const loadApp1 = async (
registeredName = BUILD_NAME,
entryGlobalName?: string,
) => {
// Drop any container global left behind by earlier tests so the entry
// script is executed again instead of being reused.
delete (CurrentGlobal as Record<string, unknown>)[BUILD_NAME];
const FM = new ModuleFederation({
name: HOST_NAME,
version: '1.0.1',
remotes: [remoteOf(ENTRY1, registeredName, entryGlobalName)],
});
const mod = await FM.loadRemote<Promise<() => string>>(
`${registeredName}/say`,
);
assert(mod);
expect(await mod()).toBe('hello app1 entry1');
return FM;
};
const setBuildVersion = (
FM: ModuleFederation,
registeredName: string,
buildVersion: string,
) => {
const loaded = FM.moduleCache.get(registeredName);
assert(loaded);
loaded.remoteInfo.buildVersion = buildVersion;
};
const addInstance = (name: string, version?: string, id?: string) => {
const instance = new ModuleFederation({ name, version, remotes: [] });
if (id !== undefined) {
instance.options.id = id;
}
setGlobalFederationInstance(instance);
return instance;
};
const forceReRegister = (
FM: ModuleFederation,
registeredName = BUILD_NAME,
entryGlobalName?: string,
) => {
const warnSpy = rs.spyOn(console, 'warn').mockImplementation(() => {});
try {
FM.registerRemotes(
[remoteOf(ENTRY2, registeredName, entryGlobalName)],
{
force: true,
},
);
return warnSpy.mock.calls
.flat()
.filter(
(arg): arg is string =>
typeof arg === 'string' &&
arg.includes('__FEDERATION__.__INSTANCES__'),
);
} finally {
warnSpy.mockRestore();
}
};
const makeShared = (from: string, useIn: string[], loaded: boolean) =>
({
version: '18.0.0',
get: () => () => ({}),
shareConfig: {},
scope: ['default'],
useIn,
from,
deps: [],
loaded,
strategy: 'version-first',
}) as any;
const instances = () => CurrentGlobal.__FEDERATION__.__INSTANCES__;

it('removes only the instance with the matching build version when two share a build name', async () => {
const FM = await loadApp1();
setBuildVersion(FM, BUILD_NAME, '2.0.0');
const v1 = addInstance(BUILD_NAME, '1.0.0');
const v2 = addInstance(BUILD_NAME, '2.0.0');

const warnings = forceReRegister(FM);

expect(instances()).toContain(v1);
expect(instances()).not.toContain(v2);
expect(warnings).toEqual([]);
});

it('resolves the instance through entryGlobalName when the registration alias differs from the build name', async () => {
const alias = '@register-remotes/app1-alias';
const FM = await loadApp1(alias, BUILD_NAME);
const remoteInstance = addInstance(BUILD_NAME);

const warnings = forceReRegister(FM, alias, BUILD_NAME);

expect(instances()).not.toContain(remoteInstance);
expect(FM.moduleCache.has(alias)).toBe(false);
expect(warnings).toEqual([]);

const next = await FM.loadRemote<Promise<() => string>>(`${alias}/say`);
assert(next);
expect(await next()).toBe('hello app1 entry2');
});

it('deletes unloaded shares produced by the remote instance from the global share scope', async () => {
const FM = await loadApp1();
addInstance(BUILD_NAME);
const shareScope = Global.__FEDERATION__.__SHARE__;
shareScope[BUILD_NAME] = {
default: { react: { '18.0.0': makeShared(BUILD_NAME, [], false) } },
};

forceReRegister(FM);

expect(shareScope[BUILD_NAME]).toBeUndefined();
});

it('keeps shares still consumed by another host and only drops the producer from useIn', async () => {
const FM = await loadApp1();
addInstance(BUILD_NAME);
const shareScope = Global.__FEDERATION__.__SHARE__;
const shared = makeShared(BUILD_NAME, [HOST_NAME, BUILD_NAME], true);
shareScope[BUILD_NAME] = { default: { react: { '18.0.0': shared } } };

forceReRegister(FM);

expect(shareScope[BUILD_NAME]?.default?.react?.['18.0.0']).toBe(shared);
expect(shared.useIn).toEqual([HOST_NAME]);
});

it('removes nothing and warns about ambiguity when two unversioned instances share the name', async () => {
const FM = await loadApp1();
const first = addInstance(BUILD_NAME);
const second = addInstance(BUILD_NAME);

const warnings = forceReRegister(FM);

expect(instances()).toContain(first);
expect(instances()).toContain(second);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain('ambiguous');
expect(warnings[0]).toContain('2 runtime instances');
expect(warnings[0]).toContain('"registeredName"');
});

it('warns when instances with the same name exist but none has the requested build version', async () => {
const FM = await loadApp1();
setBuildVersion(FM, BUILD_NAME, '3.0.0');
const v1 = addInstance(BUILD_NAME, '1.0.0');

const warnings = forceReRegister(FM);

expect(instances()).toContain(v1);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain('"3.0.0"');
expect(warnings[0]).toContain('found versions: 1.0.0');
});

it('stays silent and still clears the module cache for a container without a runtime instance', async () => {
const FM = await loadApp1();
const unrelated = addInstance('@register-remotes/unrelated');
const before = [...instances()];

const warnings = forceReRegister(FM);

expect(warnings).toEqual([]);
expect(instances()).toEqual(before);
expect(instances()).toContain(unrelated);
expect(FM.moduleCache.has(BUILD_NAME)).toBe(false);

const next = await FM.loadRemote<Promise<() => string>>(
`${BUILD_NAME}/say`,
);
assert(next);
expect(await next()).toBe('hello app1 entry2');
});

it('resolves a versioned instance by options.name and options.version when options.id is custom', async () => {
const FM = await loadApp1();
setBuildVersion(FM, BUILD_NAME, '2.0.0');
const custom = addInstance(BUILD_NAME, '2.0.0', 'custom-build-id');

const warnings = forceReRegister(FM);

expect(instances()).not.toContain(custom);
expect(warnings).toEqual([]);
});
});
it('reloads manifest snapshots when a manifest remote is force registered with the same entry', async () => {
const manifestUrl =
'http://localhost:1111/resources/register-remotes/manifest/federation-manifest.json';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect } from '@rstest/core';
import { resolveRemoteRuntimeInstance } from '../src/remote/resolveRemoteRuntimeInstance';
import type { ModuleFederation } from '../src/core';
import type { RemoteInfo } from '../src/type';

const instance = (name: string, version?: string, id = '') =>
({ name, options: { id, name, version } }) as unknown as ModuleFederation;

const remoteInfo = (
name: string,
extra: Partial<RemoteInfo> = {},
): RemoteInfo => ({
name,
entry: 'http://localhost/remoteEntry.js',
type: 'global',
entryGlobalName: name,
shareScope: 'default',
...extra,
});

describe('resolveRemoteRuntimeInstance', () => {
it('matches registered name + buildVersion by composed options.id', () => {
const target = instance('other', undefined, 'app:1.0.0');
const res = resolveRemoteRuntimeInstance(
remoteInfo('app', { buildVersion: '1.0.0' }),
[instance('app', '2.0.0'), target],
);
expect(res.instance).toBe(target);
expect(res.index).toBe(1);
expect(res.level).toBe('registeredName+buildVersion');
expect(res.identity).toEqual({
registeredName: 'app',
buildName: 'app',
buildVersion: '1.0.0',
instanceId: 'app:1.0.0',
});
});

it('matches registered name + buildVersion by options.name/options.version', () => {
const target = instance('app', '1.0.0', 'custom-id');
const res = resolveRemoteRuntimeInstance(
remoteInfo('app', { buildVersion: '1.0.0' }),
[target, instance('app', '2.0.0')],
);
expect(res.instance).toBe(target);
expect(res.ambiguous).toBe(false);
});

it('falls through to buildName + buildVersion when the alias does not match', () => {
const target = instance('build', '1.0.0');
const res = resolveRemoteRuntimeInstance(
remoteInfo('alias', { entryGlobalName: 'build', buildVersion: '1.0.0' }),
[instance('alias', '2.0.0'), target],
);
expect(res.instance).toBe(target);
expect(res.level).toBe('buildName+buildVersion');
});

it('never degrades a versioned lookup into a name-only match', () => {
const res = resolveRemoteRuntimeInstance(
remoteInfo('alias', { entryGlobalName: 'build', buildVersion: '9.9.9' }),
[instance('alias'), instance('build', '1.0.0')],
);
expect(res.instance).toBeUndefined();
expect(res.index).toBe(-1);
expect(res.ambiguous).toBe(false);
expect(res.level).toBeUndefined();
});

it('matches registered name alone when no buildVersion is known', () => {
const target = instance('app');
const res = resolveRemoteRuntimeInstance(remoteInfo('app'), [
instance('other'),
target,
]);
expect(res.instance).toBe(target);
expect(res.level).toBe('registeredName');
});

it('matches entryGlobalName alone only when unambiguous', () => {
const target = instance('build');
const ok = resolveRemoteRuntimeInstance(
remoteInfo('alias', { entryGlobalName: 'build' }),
[instance('other'), target],
);
expect(ok.instance).toBe(target);
expect(ok.level).toBe('buildName');

const dup = resolveRemoteRuntimeInstance(
remoteInfo('alias', { entryGlobalName: 'build' }),
[instance('build'), instance('build')],
);
expect(dup.instance).toBeUndefined();
expect(dup.ambiguous).toBe(true);
expect(dup.level).toBe('buildName');
expect(dup.candidateCount).toBe(2);
});

it('reports ambiguity instead of guessing when a level yields several candidates', () => {
const res = resolveRemoteRuntimeInstance(
remoteInfo('app', { buildVersion: '1.0.0' }),
[instance('app', '1.0.0'), instance('app', '1.0.0'), instance('app')],
);
expect(res.instance).toBeUndefined();
expect(res.ambiguous).toBe(true);
expect(res.level).toBe('registeredName+buildVersion');
expect(res.candidateCount).toBe(2);
});

it('does not use entryGlobalName as a level when it equals the registered name', () => {
const res = resolveRemoteRuntimeInstance(
remoteInfo('app', { entryGlobalName: 'app' }),
[instance('build')],
);
expect(res.instance).toBeUndefined();
expect(res.candidateCount).toBe(0);
});
});
Loading
Loading