From a700783cbf9e2cd1ce9c1c5f3dd4f8a3e6f2c355 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 7 Feb 2026 02:35:47 -0800 Subject: [PATCH 01/13] feat(runtime): add unloadRemote teardown API --- .changeset/soft-carpets-develop.md | 12 + .../__tests__/register-remotes.spec.ts | 172 ++++++++++ packages/runtime-core/src/core.ts | 4 + packages/runtime-core/src/remote/index.ts | 299 ++++++++++++------ packages/runtime/__tests__/api.spec.ts | 27 +- .../__tests__/register-remotes.spec.ts | 71 +++++ packages/runtime/src/index.ts | 8 + .../__tests__/init.spec.ts | 78 +++++ packages/webpack-bundler-runtime/src/init.ts | 6 +- 9 files changed, 569 insertions(+), 108 deletions(-) create mode 100644 .changeset/soft-carpets-develop.md create mode 100644 packages/webpack-bundler-runtime/__tests__/init.spec.ts diff --git a/.changeset/soft-carpets-develop.md b/.changeset/soft-carpets-develop.md new file mode 100644 index 00000000000..78a469b2d15 --- /dev/null +++ b/.changeset/soft-carpets-develop.md @@ -0,0 +1,12 @@ +--- +'@module-federation/runtime': minor +'@module-federation/runtime-core': minor +'@module-federation/webpack-bundler-runtime': minor +--- + +feat(runtime): add public `unloadRemote(nameOrAlias)` API for deterministic remote teardown + +- Exposes `unloadRemote` on `ModuleFederation` and top-level `@module-federation/runtime` exports. +- Performs idempotent unload (`false` when remote is not registered). +- Clears runtime remote references and unload bookkeeping (`moduleCache`, global loading markers, snapshot/manifest entries, preloaded map entries, and id-to-remote mappings). +- Clears webpack bundler runtime module cache for unloaded remotes (`__webpack_require__.c`/`m`) and resets remote load markers for matched module ids. diff --git a/packages/runtime-core/__tests__/register-remotes.spec.ts b/packages/runtime-core/__tests__/register-remotes.spec.ts index 174d914c491..f69da733651 100644 --- a/packages/runtime-core/__tests__/register-remotes.spec.ts +++ b/packages/runtime-core/__tests__/register-remotes.spec.ts @@ -1,5 +1,6 @@ import { assert, describe, it, expect } from 'vitest'; import { ModuleFederation } from '../src/index'; +import { Global } from '../src/global'; describe('ModuleFederation', () => { it('registers new remotes and loads them correctly', async () => { @@ -102,4 +103,175 @@ describe('ModuleFederation', () => { // Value is different from the registered remote expect(newApp1Res).toBe('hello app1 entry2'); }); + + it('unloads loaded remote by name and allows re-registration', async () => { + const FM = new ModuleFederation({ + name: '@federation/instance-unload-name', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + }, + ], + }); + const app1Module = await FM.loadRemote string>>( + '@register-remotes/app2/say', + ); + assert(app1Module); + expect(await app1Module()).toBe('hello app2'); + + expect(FM.unloadRemote('@register-remotes/app2')).toBe(true); + expect( + FM.options.remotes.find((r) => r.name === '@register-remotes/app2'), + ).toBeUndefined(); + + await expect( + FM.loadRemote string>>('@register-remotes/app2/say'), + ).rejects.toThrow(); + + FM.registerRemotes([ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + }, + ]); + const app1ModuleReloaded = await FM.loadRemote string>>( + '@register-remotes/app2/say', + ); + assert(app1ModuleReloaded); + expect(await app1ModuleReloaded()).toBe('hello app2'); + }); + + it('unloads by alias and clears preloaded entries', async () => { + const FM = new ModuleFederation({ + name: '@federation/instance-unload-alias', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + }, + ], + }); + const app1Module = await FM.loadRemote string>>('app2/say'); + assert(app1Module); + expect(await app1Module()).toBe('hello app2'); + + Global.__FEDERATION__.__PRELOADED_MAP__.set('app2/say', true); + Global.__FEDERATION__.__PRELOADED_MAP__.set( + '@register-remotes/app2/say', + true, + ); + + expect(FM.unloadRemote('app2')).toBe(true); + expect(Global.__FEDERATION__.__PRELOADED_MAP__.get('app2/say')).toBe( + undefined, + ); + expect( + Global.__FEDERATION__.__PRELOADED_MAP__.get('@register-remotes/app2/say'), + ).toBe(undefined); + }); + + it('returns false when unloading unknown remote', () => { + const FM = new ModuleFederation({ + name: '@federation/instance-unload-miss', + version: '1.0.1', + remotes: [], + }); + + expect(FM.unloadRemote('unknown-remote')).toBe(false); + }); + + it('clears idToRemoteMap entries for unloaded remote', async () => { + const FM = new ModuleFederation({ + name: '@federation/instance-unload-id-map', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + }, + ], + }); + await FM.loadRemote string>>('@register-remotes/app2/say'); + await FM.loadRemote string>>('app2/say'); + expect( + Object.values(FM.remoteHandler.idToRemoteMap).some( + (item) => item.name === '@register-remotes/app2', + ), + ).toBe(true); + + FM.unloadRemote('@register-remotes/app2'); + + expect( + Object.values(FM.remoteHandler.idToRemoteMap).some( + (item) => item.name === '@register-remotes/app2', + ), + ).toBe(false); + }); + + it('clears webpack module cache and remote marker for unloaded remote only', () => { + const FM = new ModuleFederation({ + name: '@federation/instance-unload-bundler-cache', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + }, + ], + }); + + const targetMapping: any = ['default', './say', 'external-target']; + targetMapping.p = Promise.resolve(1); + const untouchedMapping: any = ['default', './say', 'external-untouched']; + untouchedMapping.p = Promise.resolve(2); + + const webpackRequire: any = { + c: { + target: { id: 'target' }, + untouched: { id: 'untouched' }, + }, + m: { + target: () => null, + untouched: () => null, + }, + federation: { + bundlerRuntimeOptions: { + remotes: { + idToRemoteMap: { + target: [ + { externalType: 'script', name: '@register-remotes/app2' }, + ], + untouched: [{ externalType: 'script', name: 'other-remote' }], + }, + idToExternalAndNameMapping: { + target: targetMapping, + untouched: untouchedMapping, + }, + }, + }, + }, + }; + (FM as any)[Symbol.for('mf_webpack_require')] = webpackRequire; + + expect(FM.unloadRemote('@register-remotes/app2')).toBe(true); + expect(webpackRequire.c.target).toBeUndefined(); + expect(webpackRequire.m.target).toBeUndefined(); + expect(targetMapping.p).toBeUndefined(); + expect(webpackRequire.c.untouched).toBeDefined(); + expect(webpackRequire.m.untouched).toBeDefined(); + expect(untouchedMapping.p).toBeDefined(); + }); }); diff --git a/packages/runtime-core/src/core.ts b/packages/runtime-core/src/core.ts index 56ab22aabab..4ab38ab06d5 100644 --- a/packages/runtime-core/src/core.ts +++ b/packages/runtime-core/src/core.ts @@ -345,6 +345,10 @@ export class ModuleFederation { return this.remoteHandler.registerRemotes(remotes, options); } + unloadRemote(nameOrAlias: string): boolean { + return this.remoteHandler.unloadRemote(nameOrAlias); + } + registerShared(shared: UserOptions['shared']) { this.sharedHandler.registerShared(this.options, { ...this.options, diff --git a/packages/runtime-core/src/remote/index.ts b/packages/runtime-core/src/remote/index.ts index bb101f25450..1b55a08b6c3 100644 --- a/packages/runtime-core/src/remote/index.ts +++ b/packages/runtime-core/src/remote/index.ts @@ -4,6 +4,8 @@ import { composeKeyWithSeparator, ModuleInfo, GlobalModuleInfo, + decodeName, + ENCODE_NAME_PREFIX, } from '@module-federation/sdk'; import { getShortErrorMsg, @@ -317,6 +319,18 @@ export class RemoteHandler { }); } + unloadRemote(nameOrAlias: string): boolean { + const { host } = this; + const remote = host.options.remotes.find( + (item) => item.name === nameOrAlias || item.alias === nameOrAlias, + ); + if (!remote) { + return false; + } + this.removeRemote(remote); + return true; + } + async getRemoteModuleAndOptions(options: { id: string }): Promise<{ module: Module; moduleOptions: ModuleOptions; @@ -472,81 +486,70 @@ export class RemoteHandler { host.options.remotes.splice(remoteIndex, 1); } const loadedModule = host.moduleCache.get(remote.name); - if (loadedModule) { - const remoteInfo = loadedModule.remoteInfo; - const key = remoteInfo.entryGlobalName as keyof typeof CurrentGlobal; - - if (CurrentGlobal[key]) { - if ( - Object.getOwnPropertyDescriptor(CurrentGlobal, key)?.configurable - ) { - delete CurrentGlobal[key]; - } else { - // @ts-ignore - CurrentGlobal[key] = undefined; - } + const remoteInfo = loadedModule + ? loadedModule.remoteInfo + : getRemoteInfo(remote); + const key = remoteInfo.entryGlobalName as keyof typeof CurrentGlobal; + + if (CurrentGlobal[key]) { + if (Object.getOwnPropertyDescriptor(CurrentGlobal, key)?.configurable) { + delete CurrentGlobal[key]; + } else { + // @ts-ignore + CurrentGlobal[key] = undefined; } - const remoteEntryUniqueKey = getRemoteEntryUniqueKey( - loadedModule.remoteInfo, - ); + } + const remoteEntryUniqueKey = getRemoteEntryUniqueKey(remoteInfo); - if (globalLoading[remoteEntryUniqueKey]) { - delete globalLoading[remoteEntryUniqueKey]; - } + if (globalLoading[remoteEntryUniqueKey]) { + delete globalLoading[remoteEntryUniqueKey]; + } - host.snapshotHandler.manifestCache.delete(remoteInfo.entry); - - // delete unloaded shared and instance - let remoteInsId = remoteInfo.buildVersion - ? composeKeyWithSeparator(remoteInfo.name, remoteInfo.buildVersion) - : remoteInfo.name; - const remoteInsIndex = - CurrentGlobal.__FEDERATION__.__INSTANCES__.findIndex((ins) => { - if (remoteInfo.buildVersion) { - return ins.options.id === remoteInsId; - } else { - return ins.name === remoteInsId; - } - }); - if (remoteInsIndex !== -1) { - const remoteIns = - CurrentGlobal.__FEDERATION__.__INSTANCES__[remoteInsIndex]; - remoteInsId = remoteIns.options.id || remoteInsId; - const globalShareScopeMap = getGlobalShareScope(); - - let isAllSharedNotUsed = true; - const needDeleteKeys: Array<[string, string, string, string]> = []; - Object.keys(globalShareScopeMap).forEach((instId) => { - const shareScopeMap = globalShareScopeMap[instId]; - shareScopeMap && - Object.keys(shareScopeMap).forEach((shareScope) => { - const shareScopeVal = shareScopeMap[shareScope]; - shareScopeVal && - Object.keys(shareScopeVal).forEach((shareName) => { - const sharedPkgs = shareScopeVal[shareName]; - sharedPkgs && - Object.keys(sharedPkgs).forEach((shareVersion) => { - const shared = sharedPkgs[shareVersion]; - if ( - shared && - typeof shared === 'object' && - shared.from === remoteInfo.name - ) { - if (shared.loaded || shared.loading) { - shared.useIn = shared.useIn.filter( - (usedHostName) => - usedHostName !== remoteInfo.name, - ); - if (shared.useIn.length) { - isAllSharedNotUsed = false; - } else { - needDeleteKeys.push([ - instId, - shareScope, - shareName, - shareVersion, - ]); - } + host.snapshotHandler.manifestCache.delete(remoteInfo.entry); + this.clearBundlerRemoteModuleCache(remote); + + // delete unloaded shared and instance + let remoteInsId = remoteInfo.buildVersion + ? composeKeyWithSeparator(remoteInfo.name, remoteInfo.buildVersion) + : remoteInfo.name; + const remoteInsIndex = + CurrentGlobal.__FEDERATION__.__INSTANCES__.findIndex((ins) => { + if (remoteInfo.buildVersion) { + return ins.options.id === remoteInsId; + } else { + return ins.name === remoteInsId; + } + }); + if (remoteInsIndex !== -1) { + const remoteIns = + CurrentGlobal.__FEDERATION__.__INSTANCES__[remoteInsIndex]; + remoteInsId = remoteIns.options.id || remoteInsId; + const globalShareScopeMap = getGlobalShareScope(); + + let isAllSharedNotUsed = true; + const needDeleteKeys: Array<[string, string, string, string]> = []; + Object.keys(globalShareScopeMap).forEach((instId) => { + const shareScopeMap = globalShareScopeMap[instId]; + shareScopeMap && + Object.keys(shareScopeMap).forEach((shareScope) => { + const shareScopeVal = shareScopeMap[shareScope]; + shareScopeVal && + Object.keys(shareScopeVal).forEach((shareName) => { + const sharedPkgs = shareScopeVal[shareName]; + sharedPkgs && + Object.keys(sharedPkgs).forEach((shareVersion) => { + const shared = sharedPkgs[shareVersion]; + if ( + shared && + typeof shared === 'object' && + shared.from === remoteInfo.name + ) { + if (shared.loaded || shared.loading) { + shared.useIn = shared.useIn.filter( + (usedHostName) => usedHostName !== remoteInfo.name, + ); + if (shared.useIn.length) { + isAllSharedNotUsed = false; } else { needDeleteKeys.push([ instId, @@ -555,48 +558,134 @@ export class RemoteHandler { shareVersion, ]); } + } else { + needDeleteKeys.push([ + instId, + shareScope, + shareName, + shareVersion, + ]); } - }); - }); - }); - }); + } + }); + }); + }); + }); - if (isAllSharedNotUsed) { - remoteIns.shareScopeMap = {}; - delete globalShareScopeMap[remoteInsId]; - } - needDeleteKeys.forEach( - ([insId, shareScope, shareName, shareVersion]) => { - delete globalShareScopeMap[insId]?.[shareScope]?.[shareName]?.[ - shareVersion - ]; - }, - ); - CurrentGlobal.__FEDERATION__.__INSTANCES__.splice(remoteInsIndex, 1); + if (isAllSharedNotUsed) { + remoteIns.shareScopeMap = {}; + delete globalShareScopeMap[remoteInsId]; } + needDeleteKeys.forEach( + ([insId, shareScope, shareName, shareVersion]) => { + delete globalShareScopeMap[insId]?.[shareScope]?.[shareName]?.[ + shareVersion + ]; + }, + ); + CurrentGlobal.__FEDERATION__.__INSTANCES__.splice(remoteInsIndex, 1); + } - const { hostGlobalSnapshot } = getGlobalRemoteInfo(remote, host); - if (hostGlobalSnapshot) { - const remoteKey = - hostGlobalSnapshot && - 'remotesInfo' in hostGlobalSnapshot && - hostGlobalSnapshot.remotesInfo && - getInfoWithoutType(hostGlobalSnapshot.remotesInfo, remote.name).key; - if (remoteKey) { - delete hostGlobalSnapshot.remotesInfo[remoteKey]; - if ( - //eslint-disable-next-line no-extra-boolean-cast - Boolean(Global.__FEDERATION__.__MANIFEST_LOADING__[remoteKey]) - ) { - delete Global.__FEDERATION__.__MANIFEST_LOADING__[remoteKey]; - } + const { hostGlobalSnapshot } = getGlobalRemoteInfo(remote, host); + if (hostGlobalSnapshot) { + const remoteKey = + hostGlobalSnapshot && + 'remotesInfo' in hostGlobalSnapshot && + hostGlobalSnapshot.remotesInfo && + getInfoWithoutType(hostGlobalSnapshot.remotesInfo, remote.name).key; + if (remoteKey) { + delete hostGlobalSnapshot.remotesInfo[remoteKey]; + if ( + //eslint-disable-next-line no-extra-boolean-cast + Boolean(Global.__FEDERATION__.__MANIFEST_LOADING__[remoteKey]) + ) { + delete Global.__FEDERATION__.__MANIFEST_LOADING__[remoteKey]; } } - - host.moduleCache.delete(remote.name); } + + host.moduleCache.delete(remote.name); + Object.keys(this.idToRemoteMap).forEach((id) => { + if (this.idToRemoteMap[id]?.name === remote.name) { + delete this.idToRemoteMap[id]; + } + }); + + const remotePrefixes = [remote.name, remote.alias].filter( + Boolean, + ) as string[]; + const preloadedMap = Global.__FEDERATION__.__PRELOADED_MAP__; + Array.from(preloadedMap.keys()).forEach((key) => { + if ( + remotePrefixes.some( + (prefix) => key === prefix || key.startsWith(`${prefix}/`), + ) + ) { + preloadedMap.delete(key); + } + }); } catch (err) { logger.log('removeRemote fail: ', err); } } + + private clearBundlerRemoteModuleCache(remote: Remote): void { + const hostWithInternal = this.host as ModuleFederation & { + [key: symbol]: any; + }; + const webpackRequire = hostWithInternal[Symbol.for('mf_webpack_require')]; + if (!webpackRequire) { + return; + } + const remotesOptions = + webpackRequire?.federation?.bundlerRuntimeOptions?.remotes; + if (!remotesOptions) { + return; + } + const { idToRemoteMap = {}, idToExternalAndNameMapping = {} } = + remotesOptions as { + idToRemoteMap?: Record>; + idToExternalAndNameMapping?: Record; + }; + + const candidates = new Set( + [remote.name, remote.alias].filter(Boolean) as string[], + ); + const normalized = (value: string) => { + try { + return decodeName(value, ENCODE_NAME_PREFIX); + } catch { + return value; + } + }; + + Object.entries(idToRemoteMap).forEach(([moduleId, remoteInfos]) => { + if (!Array.isArray(remoteInfos)) { + return; + } + const matched = remoteInfos.some((remoteInfo) => { + if (!remoteInfo?.name) { + return false; + } + const remoteName = remoteInfo.name; + return ( + candidates.has(remoteName) || candidates.has(normalized(remoteName)) + ); + }); + if (!matched) { + return; + } + + delete webpackRequire.c[moduleId]; + delete webpackRequire.m[moduleId]; + const mappingItem = idToExternalAndNameMapping[moduleId]; + if ( + mappingItem && + typeof mappingItem === 'object' && + 'p' in mappingItem + ) { + delete mappingItem.p; + } + }); + } } diff --git a/packages/runtime/__tests__/api.spec.ts b/packages/runtime/__tests__/api.spec.ts index 8214cd6c945..ba8333bc010 100644 --- a/packages/runtime/__tests__/api.spec.ts +++ b/packages/runtime/__tests__/api.spec.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { init } from '../src'; +import { describe, it, expect, vi } from 'vitest'; +import { init, unloadRemote } from '../src'; // eslint-disable-next-line max-lines-per-function describe('api', () => { @@ -10,6 +10,7 @@ describe('api', () => { }); expect(FM.loadShare).not.toBe(null); expect(FM.loadRemote).not.toBe(null); + expect(FM.unloadRemote).not.toBe(null); }); it('initializes with the same name and returns the same instance', () => { const FM1 = init({ @@ -142,4 +143,26 @@ describe('api', () => { /The alias @scope of remote @scope\/component is not allowed to be the prefix of @federation\/button name or alias/, ); }); + + it('exports top-level unloadRemote wrapper and delegates to instance', async () => { + const FM = init({ + name: '@federation/unload-wrapper', + remotes: [ + { + name: '@register-remotes/app1', + alias: 'app1', + entry: + 'http://localhost:1111/resources/register-remotes/app1/federation-remote-entry.js', + }, + ], + }); + await FM.loadRemote('@register-remotes/app1/say'); + expect(unloadRemote('@register-remotes/app1')).toBe(true); + }); + + it('throws for top-level unloadRemote wrapper when instance is missing', async () => { + vi.resetModules(); + const runtime = await import('../src/index'); + expect(() => runtime.unloadRemote('missing')).toThrow(); + }); }); diff --git a/packages/runtime/__tests__/register-remotes.spec.ts b/packages/runtime/__tests__/register-remotes.spec.ts index 174d914c491..918391396cf 100644 --- a/packages/runtime/__tests__/register-remotes.spec.ts +++ b/packages/runtime/__tests__/register-remotes.spec.ts @@ -102,4 +102,75 @@ describe('ModuleFederation', () => { // Value is different from the registered remote expect(newApp1Res).toBe('hello app1 entry2'); }); + + it('unloads remote by name and supports re-registration', async () => { + const FM = new ModuleFederation({ + name: '@federation/runtime-unload-name', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + }, + ], + }); + const app1Module = await FM.loadRemote string>>( + '@register-remotes/app2/say', + ); + assert(app1Module); + expect(await app1Module()).toBe('hello app2'); + + expect(FM.unloadRemote('@register-remotes/app2')).toBe(true); + await expect( + FM.loadRemote string>>('@register-remotes/app2/say'), + ).rejects.toThrow(); + + FM.registerRemotes([ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + }, + ]); + const app1ModuleReloaded = await FM.loadRemote string>>( + '@register-remotes/app2/say', + ); + assert(app1ModuleReloaded); + expect(await app1ModuleReloaded()).toBe('hello app2'); + }); + + it('unloads remote by alias', async () => { + const FM = new ModuleFederation({ + name: '@federation/runtime-unload-alias', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + }, + ], + }); + const app1Module = await FM.loadRemote string>>('app2/say'); + assert(app1Module); + expect(await app1Module()).toBe('hello app2'); + + expect(FM.unloadRemote('app2')).toBe(true); + await expect( + FM.loadRemote string>>('app2/say'), + ).rejects.toThrow(); + }); + + it('returns false for missing remote unload', () => { + const FM = new ModuleFederation({ + name: '@federation/runtime-unload-miss', + version: '1.0.1', + remotes: [], + }); + expect(FM.unloadRemote('missing')).toBe(false); + }); }); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index d4253306f07..c9cd6f4906e 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -101,6 +101,14 @@ export function registerRemotes( return FederationInstance.registerRemotes.apply(FederationInstance, args); } +export function unloadRemote( + ...args: Parameters +): ReturnType { + assert(FederationInstance, getShortErrorMsg(RUNTIME_009, runtimeDescMap)); + // eslint-disable-next-line prefer-spread + return FederationInstance.unloadRemote.apply(FederationInstance, args); +} + export function registerPlugins( ...args: Parameters ): ReturnType { diff --git a/packages/webpack-bundler-runtime/__tests__/init.spec.ts b/packages/webpack-bundler-runtime/__tests__/init.spec.ts new file mode 100644 index 00000000000..d7d7b79598b --- /dev/null +++ b/packages/webpack-bundler-runtime/__tests__/init.spec.ts @@ -0,0 +1,78 @@ +jest.mock('@module-federation/runtime', () => ({ + init: jest.fn(), +})); + +import { init as runtimeInit } from '@module-federation/runtime'; +import { init } from '../src/init'; + +describe('init', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('attaches webpackRequire to runtime instance using internal symbol', () => { + const instance = {}; + (runtimeInit as jest.Mock).mockReturnValue(instance); + + const webpackRequire: any = { + federation: { + initOptions: { + name: 'host', + remotes: [], + }, + runtime: { + init: runtimeInit, + }, + sharedFallback: undefined, + bundlerRuntime: { + getSharedFallbackGetter: jest.fn(), + }, + libraryType: 'var', + }, + }; + + const result = init({ webpackRequire }); + + expect(result).toBe(instance); + expect( + (instance as Record)[Symbol.for('mf_webpack_require')], + ).toBe(webpackRequire); + }); + + test('preserves init behavior while appending tree-shake plugin', () => { + const instance = { foo: 'bar' }; + (runtimeInit as jest.Mock).mockReturnValue(instance); + + const existingPlugin = { + name: 'existing-plugin', + beforeInit: jest.fn((args) => args), + }; + + const webpackRequire: any = { + federation: { + initOptions: { + name: 'host', + remotes: [], + plugins: [existingPlugin], + }, + runtime: { + init: runtimeInit, + }, + sharedFallback: undefined, + bundlerRuntime: { + getSharedFallbackGetter: jest.fn(), + }, + libraryType: 'var', + }, + }; + + const result = init({ webpackRequire }); + + expect(result).toBe(instance); + expect(runtimeInit).toHaveBeenCalledTimes(1); + const calledOptions = (runtimeInit as jest.Mock).mock.calls[0][0]; + expect(calledOptions.plugins[0]).toBe(existingPlugin); + expect(calledOptions.plugins).toHaveLength(2); + expect(typeof calledOptions.plugins[1].beforeInit).toBe('function'); + }); +}); diff --git a/packages/webpack-bundler-runtime/src/init.ts b/packages/webpack-bundler-runtime/src/init.ts index fc2e8260a5a..058497c9d32 100644 --- a/packages/webpack-bundler-runtime/src/init.ts +++ b/packages/webpack-bundler-runtime/src/init.ts @@ -126,5 +126,9 @@ export function init({ webpackRequire }: { webpackRequire: WebpackRequire }) { initOptions.plugins ||= []; initOptions.plugins.push(treeShakingSharePlugin()); - return runtime!.init(initOptions); + const instance = runtime!.init(initOptions); + (instance as unknown as Record)[ + Symbol.for('mf_webpack_require') + ] = webpackRequire; + return instance; } From b64a4b07ac490453a3e0ea077267f658dd7f6c87 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 7 Feb 2026 19:49:45 -0800 Subject: [PATCH 02/13] fix(core): set actionlint working directory to runner temp --- .github/workflows/actionlint.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml index c0819fcae07..08f0f1bd14f 100644 --- a/.github/workflows/actionlint.yml +++ b/.github/workflows/actionlint.yml @@ -21,3 +21,4 @@ jobs: NODE_PATH: ${{ runner.temp }}/node_modules with: matcher: true + working-directory: ${{ runner.temp }} From 066c4e3486779299bd0161c4fc51decbf6dd24cc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 7 Feb 2026 23:06:31 -0800 Subject: [PATCH 03/13] fix(rsbuild-plugin): resolve parseOptions type import --- packages/enhanced/src/rspack.ts | 1 + packages/rsbuild-plugin/src/cli/index.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/enhanced/src/rspack.ts b/packages/enhanced/src/rspack.ts index e6154c3ddf2..0501205e8e1 100644 --- a/packages/enhanced/src/rspack.ts +++ b/packages/enhanced/src/rspack.ts @@ -4,4 +4,5 @@ export { TreeShakingSharedPlugin, PLUGIN_NAME, } from '@module-federation/rspack/plugin'; +export { parseOptions } from './lib/container/options'; export { createModuleFederationConfig } from '@module-federation/sdk'; diff --git a/packages/rsbuild-plugin/src/cli/index.ts b/packages/rsbuild-plugin/src/cli/index.ts index 6a1e0ded1f0..525cb8b34b4 100644 --- a/packages/rsbuild-plugin/src/cli/index.ts +++ b/packages/rsbuild-plugin/src/cli/index.ts @@ -1,8 +1,8 @@ -import { parseOptions } from '@module-federation/enhanced'; import { ModuleFederationPlugin, TreeShakingSharedPlugin, PLUGIN_NAME, + parseOptions, } from '@module-federation/enhanced/rspack'; import { isRequiredVersion, getManifestFileName } from '@module-federation/sdk'; import pkgJson from '../../package.json'; From f6d5aa3f3b9d571e87c11d28b758b672b62b37f8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 7 Feb 2026 23:07:43 -0800 Subject: [PATCH 04/13] ci(core): align devtools build setup with checkout-install --- .github/workflows/devtools.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/devtools.yml b/.github/workflows/devtools.yml index cb28cff7ff2..0f5cac56f41 100644 --- a/.github/workflows/devtools.yml +++ b/.github/workflows/devtools.yml @@ -42,6 +42,9 @@ jobs: cache: 'pnpm' cache-dependency-path: '**/pnpm-lock.yaml' + - name: Remove cached node_modules + run: rm -rf node_modules .nx + - name: Set Playwright cache status run: | if [ -d "$HOME/.cache/ms-playwright" ] || [ -d "$HOME/.cache/Cypress" ]; then @@ -54,13 +57,15 @@ jobs: uses: nrwl/nx-set-shas@v4 - name: Install Dependencies - run: pnpm install --frozen-lockfile && find . -maxdepth 6 -type d \( -name ".cache" -o -name ".modern-js" \) -exec rm -rf {} + + run: pnpm install --frozen-lockfile - name: Install Cypress run: npx cypress install - name: Run Affected Build - run: npx nx run-many --targets=build --projects=tag:type:pkg + run: | + npx nx run-many --targets=build --projects=tag:type:pkg --parallel=4 --skip-nx-cache + npx nx run-many --targets=build --projects=tag:type:pkg --parallel=4 - name: Configuration xvfb shell: bash From 02581f86d046c73f425f599afd3365e4e4353d66 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 7 Feb 2026 23:31:45 -0800 Subject: [PATCH 05/13] fix(runtime): gate unload teardown and move bundler cache cleanup --- .../__tests__/register-remotes.spec.ts | 82 +++--- packages/runtime-core/src/remote/index.ts | 262 ++++++++---------- .../__tests__/init.spec.ts | 42 ++- packages/webpack-bundler-runtime/src/init.ts | 72 ++++- 4 files changed, 259 insertions(+), 199 deletions(-) diff --git a/packages/runtime-core/__tests__/register-remotes.spec.ts b/packages/runtime-core/__tests__/register-remotes.spec.ts index f69da733651..1ca49a694ca 100644 --- a/packages/runtime-core/__tests__/register-remotes.spec.ts +++ b/packages/runtime-core/__tests__/register-remotes.spec.ts @@ -1,6 +1,6 @@ -import { assert, describe, it, expect } from 'vitest'; +import { assert, describe, it, expect, vi } from 'vitest'; import { ModuleFederation } from '../src/index'; -import { Global } from '../src/global'; +import { CurrentGlobal, Global } from '../src/global'; describe('ModuleFederation', () => { it('registers new remotes and loads them correctly', async () => { @@ -189,6 +189,34 @@ describe('ModuleFederation', () => { expect(FM.unloadRemote('unknown-remote')).toBe(false); }); + it('does not clear global container when unloading remote not loaded by current host', () => { + const entryGlobalName = '__TEST_REMOTE_ENTRY_FROM_OTHER_HOST__'; + try { + (CurrentGlobal as any)[entryGlobalName] = { loadedBy: 'other-host' }; + + const FM = new ModuleFederation({ + name: '@federation/instance-unload-not-loaded', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + entryGlobalName, + }, + ], + }); + + expect(FM.unloadRemote('@register-remotes/app2')).toBe(true); + expect((CurrentGlobal as any)[entryGlobalName]).toEqual({ + loadedBy: 'other-host', + }); + } finally { + delete (CurrentGlobal as any)[entryGlobalName]; + } + }); + it('clears idToRemoteMap entries for unloaded remote', async () => { const FM = new ModuleFederation({ name: '@federation/instance-unload-id-map', @@ -219,7 +247,7 @@ describe('ModuleFederation', () => { ).toBe(false); }); - it('clears webpack module cache and remote marker for unloaded remote only', () => { + it('invokes bundler cache cleanup hook when unloading remote', () => { const FM = new ModuleFederation({ name: '@federation/instance-unload-bundler-cache', version: '1.0.1', @@ -233,45 +261,17 @@ describe('ModuleFederation', () => { ], }); - const targetMapping: any = ['default', './say', 'external-target']; - targetMapping.p = Promise.resolve(1); - const untouchedMapping: any = ['default', './say', 'external-untouched']; - untouchedMapping.p = Promise.resolve(2); - - const webpackRequire: any = { - c: { - target: { id: 'target' }, - untouched: { id: 'untouched' }, - }, - m: { - target: () => null, - untouched: () => null, - }, - federation: { - bundlerRuntimeOptions: { - remotes: { - idToRemoteMap: { - target: [ - { externalType: 'script', name: '@register-remotes/app2' }, - ], - untouched: [{ externalType: 'script', name: 'other-remote' }], - }, - idToExternalAndNameMapping: { - target: targetMapping, - untouched: untouchedMapping, - }, - }, - }, - }, - }; - (FM as any)[Symbol.for('mf_webpack_require')] = webpackRequire; + const clearCache = vi.fn(); + (FM as any)[Symbol.for('mf_clear_bundler_remote_module_cache')] = + clearCache; expect(FM.unloadRemote('@register-remotes/app2')).toBe(true); - expect(webpackRequire.c.target).toBeUndefined(); - expect(webpackRequire.m.target).toBeUndefined(); - expect(targetMapping.p).toBeUndefined(); - expect(webpackRequire.c.untouched).toBeDefined(); - expect(webpackRequire.m.untouched).toBeDefined(); - expect(untouchedMapping.p).toBeDefined(); + expect(clearCache).toHaveBeenCalledTimes(1); + expect(clearCache).toHaveBeenCalledWith( + expect.objectContaining({ + name: '@register-remotes/app2', + alias: 'app2', + }), + ); }); }); diff --git a/packages/runtime-core/src/remote/index.ts b/packages/runtime-core/src/remote/index.ts index 1b55a08b6c3..47cdeee0855 100644 --- a/packages/runtime-core/src/remote/index.ts +++ b/packages/runtime-core/src/remote/index.ts @@ -4,8 +4,6 @@ import { composeKeyWithSeparator, ModuleInfo, GlobalModuleInfo, - decodeName, - ENCODE_NAME_PREFIX, } from '@module-federation/sdk'; import { getShortErrorMsg, @@ -489,67 +487,90 @@ export class RemoteHandler { const remoteInfo = loadedModule ? loadedModule.remoteInfo : getRemoteInfo(remote); - const key = remoteInfo.entryGlobalName as keyof typeof CurrentGlobal; - - if (CurrentGlobal[key]) { - if (Object.getOwnPropertyDescriptor(CurrentGlobal, key)?.configurable) { - delete CurrentGlobal[key]; - } else { - // @ts-ignore - CurrentGlobal[key] = undefined; + const clearBundlerRemoteModuleCache = ( + host as ModuleFederation & { + [key: symbol]: unknown; } + )[Symbol.for('mf_clear_bundler_remote_module_cache')]; + if (typeof clearBundlerRemoteModuleCache === 'function') { + clearBundlerRemoteModuleCache(remote); } - const remoteEntryUniqueKey = getRemoteEntryUniqueKey(remoteInfo); - if (globalLoading[remoteEntryUniqueKey]) { - delete globalLoading[remoteEntryUniqueKey]; + if (remoteInfo.entry) { + host.snapshotHandler.manifestCache.delete(remoteInfo.entry); } - host.snapshotHandler.manifestCache.delete(remoteInfo.entry); - this.clearBundlerRemoteModuleCache(remote); - - // delete unloaded shared and instance - let remoteInsId = remoteInfo.buildVersion - ? composeKeyWithSeparator(remoteInfo.name, remoteInfo.buildVersion) - : remoteInfo.name; - const remoteInsIndex = - CurrentGlobal.__FEDERATION__.__INSTANCES__.findIndex((ins) => { - if (remoteInfo.buildVersion) { - return ins.options.id === remoteInsId; + // Only clean global/share state when this host actually loaded the remote. + if (loadedModule) { + const key = remoteInfo.entryGlobalName as keyof typeof CurrentGlobal; + + if (CurrentGlobal[key]) { + if ( + Object.getOwnPropertyDescriptor(CurrentGlobal, key)?.configurable + ) { + delete CurrentGlobal[key]; } else { - return ins.name === remoteInsId; + // @ts-ignore + CurrentGlobal[key] = undefined; } - }); - if (remoteInsIndex !== -1) { - const remoteIns = - CurrentGlobal.__FEDERATION__.__INSTANCES__[remoteInsIndex]; - remoteInsId = remoteIns.options.id || remoteInsId; - const globalShareScopeMap = getGlobalShareScope(); - - let isAllSharedNotUsed = true; - const needDeleteKeys: Array<[string, string, string, string]> = []; - Object.keys(globalShareScopeMap).forEach((instId) => { - const shareScopeMap = globalShareScopeMap[instId]; - shareScopeMap && - Object.keys(shareScopeMap).forEach((shareScope) => { - const shareScopeVal = shareScopeMap[shareScope]; - shareScopeVal && - Object.keys(shareScopeVal).forEach((shareName) => { - const sharedPkgs = shareScopeVal[shareName]; - sharedPkgs && - Object.keys(sharedPkgs).forEach((shareVersion) => { - const shared = sharedPkgs[shareVersion]; - if ( - shared && - typeof shared === 'object' && - shared.from === remoteInfo.name - ) { - if (shared.loaded || shared.loading) { - shared.useIn = shared.useIn.filter( - (usedHostName) => usedHostName !== remoteInfo.name, - ); - if (shared.useIn.length) { - isAllSharedNotUsed = false; + } + const remoteEntryUniqueKey = getRemoteEntryUniqueKey(remoteInfo); + + if (globalLoading[remoteEntryUniqueKey]) { + delete globalLoading[remoteEntryUniqueKey]; + } + + // delete unloaded shared and instance + let remoteInsId = remoteInfo.buildVersion + ? composeKeyWithSeparator(remoteInfo.name, remoteInfo.buildVersion) + : remoteInfo.name; + const remoteInsIndex = + CurrentGlobal.__FEDERATION__.__INSTANCES__.findIndex((ins) => { + if (remoteInfo.buildVersion) { + return ins.options.id === remoteInsId; + } else { + return ins.name === remoteInsId; + } + }); + if (remoteInsIndex !== -1) { + const remoteIns = + CurrentGlobal.__FEDERATION__.__INSTANCES__[remoteInsIndex]; + remoteInsId = remoteIns.options.id || remoteInsId; + const globalShareScopeMap = getGlobalShareScope(); + + let isAllSharedNotUsed = true; + const needDeleteKeys: Array<[string, string, string, string]> = []; + Object.keys(globalShareScopeMap).forEach((instId) => { + const shareScopeMap = globalShareScopeMap[instId]; + shareScopeMap && + Object.keys(shareScopeMap).forEach((shareScope) => { + const shareScopeVal = shareScopeMap[shareScope]; + shareScopeVal && + Object.keys(shareScopeVal).forEach((shareName) => { + const sharedPkgs = shareScopeVal[shareName]; + sharedPkgs && + Object.keys(sharedPkgs).forEach((shareVersion) => { + const shared = sharedPkgs[shareVersion]; + if ( + shared && + typeof shared === 'object' && + shared.from === remoteInfo.name + ) { + if (shared.loaded || shared.loading) { + shared.useIn = shared.useIn.filter( + (usedHostName) => + usedHostName !== remoteInfo.name, + ); + if (shared.useIn.length) { + isAllSharedNotUsed = false; + } else { + needDeleteKeys.push([ + instId, + shareScope, + shareName, + shareVersion, + ]); + } } else { needDeleteKeys.push([ instId, @@ -558,48 +579,41 @@ export class RemoteHandler { shareVersion, ]); } - } else { - needDeleteKeys.push([ - instId, - shareScope, - shareName, - shareVersion, - ]); } - } - }); - }); - }); - }); + }); + }); + }); + }); - if (isAllSharedNotUsed) { - remoteIns.shareScopeMap = {}; - delete globalShareScopeMap[remoteInsId]; + if (isAllSharedNotUsed) { + remoteIns.shareScopeMap = {}; + delete globalShareScopeMap[remoteInsId]; + } + needDeleteKeys.forEach( + ([insId, shareScope, shareName, shareVersion]) => { + delete globalShareScopeMap[insId]?.[shareScope]?.[shareName]?.[ + shareVersion + ]; + }, + ); + CurrentGlobal.__FEDERATION__.__INSTANCES__.splice(remoteInsIndex, 1); } - needDeleteKeys.forEach( - ([insId, shareScope, shareName, shareVersion]) => { - delete globalShareScopeMap[insId]?.[shareScope]?.[shareName]?.[ - shareVersion - ]; - }, - ); - CurrentGlobal.__FEDERATION__.__INSTANCES__.splice(remoteInsIndex, 1); - } - const { hostGlobalSnapshot } = getGlobalRemoteInfo(remote, host); - if (hostGlobalSnapshot) { - const remoteKey = - hostGlobalSnapshot && - 'remotesInfo' in hostGlobalSnapshot && - hostGlobalSnapshot.remotesInfo && - getInfoWithoutType(hostGlobalSnapshot.remotesInfo, remote.name).key; - if (remoteKey) { - delete hostGlobalSnapshot.remotesInfo[remoteKey]; - if ( - //eslint-disable-next-line no-extra-boolean-cast - Boolean(Global.__FEDERATION__.__MANIFEST_LOADING__[remoteKey]) - ) { - delete Global.__FEDERATION__.__MANIFEST_LOADING__[remoteKey]; + const { hostGlobalSnapshot } = getGlobalRemoteInfo(remote, host); + if (hostGlobalSnapshot) { + const remoteKey = + hostGlobalSnapshot && + 'remotesInfo' in hostGlobalSnapshot && + hostGlobalSnapshot.remotesInfo && + getInfoWithoutType(hostGlobalSnapshot.remotesInfo, remote.name).key; + if (remoteKey) { + delete hostGlobalSnapshot.remotesInfo[remoteKey]; + if ( + //eslint-disable-next-line no-extra-boolean-cast + Boolean(Global.__FEDERATION__.__MANIFEST_LOADING__[remoteKey]) + ) { + delete Global.__FEDERATION__.__MANIFEST_LOADING__[remoteKey]; + } } } } @@ -628,64 +642,4 @@ export class RemoteHandler { logger.log('removeRemote fail: ', err); } } - - private clearBundlerRemoteModuleCache(remote: Remote): void { - const hostWithInternal = this.host as ModuleFederation & { - [key: symbol]: any; - }; - const webpackRequire = hostWithInternal[Symbol.for('mf_webpack_require')]; - if (!webpackRequire) { - return; - } - const remotesOptions = - webpackRequire?.federation?.bundlerRuntimeOptions?.remotes; - if (!remotesOptions) { - return; - } - const { idToRemoteMap = {}, idToExternalAndNameMapping = {} } = - remotesOptions as { - idToRemoteMap?: Record>; - idToExternalAndNameMapping?: Record; - }; - - const candidates = new Set( - [remote.name, remote.alias].filter(Boolean) as string[], - ); - const normalized = (value: string) => { - try { - return decodeName(value, ENCODE_NAME_PREFIX); - } catch { - return value; - } - }; - - Object.entries(idToRemoteMap).forEach(([moduleId, remoteInfos]) => { - if (!Array.isArray(remoteInfos)) { - return; - } - const matched = remoteInfos.some((remoteInfo) => { - if (!remoteInfo?.name) { - return false; - } - const remoteName = remoteInfo.name; - return ( - candidates.has(remoteName) || candidates.has(normalized(remoteName)) - ); - }); - if (!matched) { - return; - } - - delete webpackRequire.c[moduleId]; - delete webpackRequire.m[moduleId]; - const mappingItem = idToExternalAndNameMapping[moduleId]; - if ( - mappingItem && - typeof mappingItem === 'object' && - 'p' in mappingItem - ) { - delete mappingItem.p; - } - }); - } } diff --git a/packages/webpack-bundler-runtime/__tests__/init.spec.ts b/packages/webpack-bundler-runtime/__tests__/init.spec.ts index d7d7b79598b..137a82638c0 100644 --- a/packages/webpack-bundler-runtime/__tests__/init.spec.ts +++ b/packages/webpack-bundler-runtime/__tests__/init.spec.ts @@ -10,11 +10,24 @@ describe('init', () => { jest.clearAllMocks(); }); - test('attaches webpackRequire to runtime instance using internal symbol', () => { + test('attaches internal symbols and clears matched remote module cache', () => { const instance = {}; (runtimeInit as jest.Mock).mockReturnValue(instance); + const targetMapping: any = ['default', './say', 'external-target']; + targetMapping.p = Promise.resolve(1); + const untouchedMapping: any = ['default', './say', 'external-untouched']; + untouchedMapping.p = Promise.resolve(2); + const webpackRequire: any = { + c: { + target: { id: 'target' }, + untouched: { id: 'untouched' }, + }, + m: { + target: () => null, + untouched: () => null, + }, federation: { initOptions: { name: 'host', @@ -27,6 +40,20 @@ describe('init', () => { bundlerRuntime: { getSharedFallbackGetter: jest.fn(), }, + bundlerRuntimeOptions: { + remotes: { + idToRemoteMap: { + target: [ + { externalType: 'script', name: '@register-remotes/app2' }, + ], + untouched: [{ externalType: 'script', name: 'other-remote' }], + }, + idToExternalAndNameMapping: { + target: targetMapping, + untouched: untouchedMapping, + }, + }, + }, libraryType: 'var', }, }; @@ -37,6 +64,19 @@ describe('init', () => { expect( (instance as Record)[Symbol.for('mf_webpack_require')], ).toBe(webpackRequire); + + const cleanup = (instance as Record)[ + Symbol.for('mf_clear_bundler_remote_module_cache') + ] as (remote: { name: string; alias?: string }) => void; + expect(typeof cleanup).toBe('function'); + cleanup({ name: '@register-remotes/app2', alias: 'app2' }); + + expect(webpackRequire.c.target).toBeUndefined(); + expect(webpackRequire.m.target).toBeUndefined(); + expect(targetMapping.p).toBeUndefined(); + expect(webpackRequire.c.untouched).toBeDefined(); + expect(webpackRequire.m.untouched).toBeDefined(); + expect(untouchedMapping.p).toBeDefined(); }); test('preserves init behavior while appending tree-shake plugin', () => { diff --git a/packages/webpack-bundler-runtime/src/init.ts b/packages/webpack-bundler-runtime/src/init.ts index 058497c9d32..ece9c28bed2 100644 --- a/packages/webpack-bundler-runtime/src/init.ts +++ b/packages/webpack-bundler-runtime/src/init.ts @@ -3,8 +3,71 @@ import { getRemoteEntry, type ModuleFederationRuntimePlugin, } from '@module-federation/runtime'; -import { ShareArgs } from '@module-federation/runtime/types'; +import type { ShareArgs, Remote } from '@module-federation/runtime/types'; import helpers from '@module-federation/runtime/helpers'; +import { decodeName, ENCODE_NAME_PREFIX } from '@module-federation/sdk'; + +const WEBPACK_REQUIRE_SYMBOL = Symbol.for('mf_webpack_require'); +const CLEAR_BUNDLER_REMOTE_MODULE_CACHE_SYMBOL = Symbol.for( + 'mf_clear_bundler_remote_module_cache', +); + +function clearBundlerRemoteModuleCache( + webpackRequire: WebpackRequire, + remote: Pick, +): void { + const remotesOptions = + webpackRequire?.federation?.bundlerRuntimeOptions?.remotes; + if (!remotesOptions) { + return; + } + + const { idToRemoteMap = {}, idToExternalAndNameMapping = {} } = + remotesOptions as { + idToRemoteMap?: Record>; + idToExternalAndNameMapping?: Record; + }; + + const candidates = new Set( + [remote.name, remote.alias].filter(Boolean) as string[], + ); + if (!candidates.size) { + return; + } + + const normalized = (value: string) => { + try { + return decodeName(value, ENCODE_NAME_PREFIX); + } catch { + return value; + } + }; + + Object.entries(idToRemoteMap).forEach(([moduleId, remoteInfos]) => { + if (!Array.isArray(remoteInfos)) { + return; + } + const matched = remoteInfos.some((remoteInfo) => { + if (!remoteInfo?.name) { + return false; + } + const remoteName = remoteInfo.name; + return ( + candidates.has(remoteName) || candidates.has(normalized(remoteName)) + ); + }); + if (!matched) { + return; + } + + delete webpackRequire.c[moduleId]; + delete webpackRequire.m[moduleId]; + const mappingItem = idToExternalAndNameMapping[moduleId]; + if (mappingItem && typeof mappingItem === 'object' && 'p' in mappingItem) { + delete mappingItem.p; + } + }); +} export function init({ webpackRequire }: { webpackRequire: WebpackRequire }) { const { initOptions, runtime, sharedFallback, bundlerRuntime, libraryType } = @@ -127,8 +190,11 @@ export function init({ webpackRequire }: { webpackRequire: WebpackRequire }) { initOptions.plugins ||= []; initOptions.plugins.push(treeShakingSharePlugin()); const instance = runtime!.init(initOptions); + (instance as unknown as Record)[WEBPACK_REQUIRE_SYMBOL] = + webpackRequire; (instance as unknown as Record)[ - Symbol.for('mf_webpack_require') - ] = webpackRequire; + CLEAR_BUNDLER_REMOTE_MODULE_CACHE_SYMBOL + ] = (remote: Pick) => + clearBundlerRemoteModuleCache(webpackRequire, remote); return instance; } From 2f17228a17133b629a7b029639da39b8cdcc4bc3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 7 Feb 2026 23:55:03 -0800 Subject: [PATCH 06/13] refactor(runtime): move unloadRemote to optional unload entry --- .changeset/soft-carpets-develop.md | 13 +- .../__tests__/register-remotes.spec.ts | 174 +------------ packages/runtime-core/src/core.ts | 4 - packages/runtime-core/src/remote/index.ts | 20 -- packages/runtime/__tests__/api.spec.ts | 27 +- .../__tests__/register-remotes.spec.ts | 71 ------ packages/runtime/__tests__/unload.spec.ts | 232 ++++++++++++++++++ packages/runtime/package.json | 13 + packages/runtime/project.json | 3 +- packages/runtime/rollup.config.cjs | 1 + packages/runtime/src/index.ts | 8 - packages/runtime/src/unload.ts | 204 +++++++++++++++ .../__tests__/init.spec.ts | 42 +--- packages/webpack-bundler-runtime/src/init.ts | 67 +---- 14 files changed, 465 insertions(+), 414 deletions(-) create mode 100644 packages/runtime/__tests__/unload.spec.ts create mode 100644 packages/runtime/src/unload.ts diff --git a/.changeset/soft-carpets-develop.md b/.changeset/soft-carpets-develop.md index 78a469b2d15..5cec098a784 100644 --- a/.changeset/soft-carpets-develop.md +++ b/.changeset/soft-carpets-develop.md @@ -4,9 +4,12 @@ '@module-federation/webpack-bundler-runtime': minor --- -feat(runtime): add public `unloadRemote(nameOrAlias)` API for deterministic remote teardown +feat(runtime): move remote unload APIs to optional `@module-federation/runtime/unload` entry -- Exposes `unloadRemote` on `ModuleFederation` and top-level `@module-federation/runtime` exports. -- Performs idempotent unload (`false` when remote is not registered). -- Clears runtime remote references and unload bookkeeping (`moduleCache`, global loading markers, snapshot/manifest entries, preloaded map entries, and id-to-remote mappings). -- Clears webpack bundler runtime module cache for unloaded remotes (`__webpack_require__.c`/`m`) and resets remote load markers for matched module ids. +- Removes `unloadRemote` from baseline `ModuleFederation` and `@module-federation/runtime` root exports to reduce default payload. +- Adds optional `@module-federation/runtime/unload` entrypoint with: + - `unloadRemote(nameOrAlias)` for the active runtime instance. + - `unloadRemoteFromInstance(instance, nameOrAlias)` for explicit instance control. +- Keeps deterministic unload behavior when using the optional entrypoint, including: + - runtime bookkeeping cleanup (`moduleCache`, manifest/snapshot markers, preloaded map entries, id-to-remote mappings), + - webpack bundler module cache cleanup (`__webpack_require__.c`/`m`) and remote load marker reset. diff --git a/packages/runtime-core/__tests__/register-remotes.spec.ts b/packages/runtime-core/__tests__/register-remotes.spec.ts index 1ca49a694ca..174d914c491 100644 --- a/packages/runtime-core/__tests__/register-remotes.spec.ts +++ b/packages/runtime-core/__tests__/register-remotes.spec.ts @@ -1,6 +1,5 @@ -import { assert, describe, it, expect, vi } from 'vitest'; +import { assert, describe, it, expect } from 'vitest'; import { ModuleFederation } from '../src/index'; -import { CurrentGlobal, Global } from '../src/global'; describe('ModuleFederation', () => { it('registers new remotes and loads them correctly', async () => { @@ -103,175 +102,4 @@ describe('ModuleFederation', () => { // Value is different from the registered remote expect(newApp1Res).toBe('hello app1 entry2'); }); - - it('unloads loaded remote by name and allows re-registration', async () => { - const FM = new ModuleFederation({ - name: '@federation/instance-unload-name', - version: '1.0.1', - remotes: [ - { - name: '@register-remotes/app2', - alias: 'app2', - entry: - 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', - }, - ], - }); - const app1Module = await FM.loadRemote string>>( - '@register-remotes/app2/say', - ); - assert(app1Module); - expect(await app1Module()).toBe('hello app2'); - - expect(FM.unloadRemote('@register-remotes/app2')).toBe(true); - expect( - FM.options.remotes.find((r) => r.name === '@register-remotes/app2'), - ).toBeUndefined(); - - await expect( - FM.loadRemote string>>('@register-remotes/app2/say'), - ).rejects.toThrow(); - - FM.registerRemotes([ - { - name: '@register-remotes/app2', - alias: 'app2', - entry: - 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', - }, - ]); - const app1ModuleReloaded = await FM.loadRemote string>>( - '@register-remotes/app2/say', - ); - assert(app1ModuleReloaded); - expect(await app1ModuleReloaded()).toBe('hello app2'); - }); - - it('unloads by alias and clears preloaded entries', async () => { - const FM = new ModuleFederation({ - name: '@federation/instance-unload-alias', - version: '1.0.1', - remotes: [ - { - name: '@register-remotes/app2', - alias: 'app2', - entry: - 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', - }, - ], - }); - const app1Module = await FM.loadRemote string>>('app2/say'); - assert(app1Module); - expect(await app1Module()).toBe('hello app2'); - - Global.__FEDERATION__.__PRELOADED_MAP__.set('app2/say', true); - Global.__FEDERATION__.__PRELOADED_MAP__.set( - '@register-remotes/app2/say', - true, - ); - - expect(FM.unloadRemote('app2')).toBe(true); - expect(Global.__FEDERATION__.__PRELOADED_MAP__.get('app2/say')).toBe( - undefined, - ); - expect( - Global.__FEDERATION__.__PRELOADED_MAP__.get('@register-remotes/app2/say'), - ).toBe(undefined); - }); - - it('returns false when unloading unknown remote', () => { - const FM = new ModuleFederation({ - name: '@federation/instance-unload-miss', - version: '1.0.1', - remotes: [], - }); - - expect(FM.unloadRemote('unknown-remote')).toBe(false); - }); - - it('does not clear global container when unloading remote not loaded by current host', () => { - const entryGlobalName = '__TEST_REMOTE_ENTRY_FROM_OTHER_HOST__'; - try { - (CurrentGlobal as any)[entryGlobalName] = { loadedBy: 'other-host' }; - - const FM = new ModuleFederation({ - name: '@federation/instance-unload-not-loaded', - version: '1.0.1', - remotes: [ - { - name: '@register-remotes/app2', - alias: 'app2', - entry: - 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', - entryGlobalName, - }, - ], - }); - - expect(FM.unloadRemote('@register-remotes/app2')).toBe(true); - expect((CurrentGlobal as any)[entryGlobalName]).toEqual({ - loadedBy: 'other-host', - }); - } finally { - delete (CurrentGlobal as any)[entryGlobalName]; - } - }); - - it('clears idToRemoteMap entries for unloaded remote', async () => { - const FM = new ModuleFederation({ - name: '@federation/instance-unload-id-map', - version: '1.0.1', - remotes: [ - { - name: '@register-remotes/app2', - alias: 'app2', - entry: - 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', - }, - ], - }); - await FM.loadRemote string>>('@register-remotes/app2/say'); - await FM.loadRemote string>>('app2/say'); - expect( - Object.values(FM.remoteHandler.idToRemoteMap).some( - (item) => item.name === '@register-remotes/app2', - ), - ).toBe(true); - - FM.unloadRemote('@register-remotes/app2'); - - expect( - Object.values(FM.remoteHandler.idToRemoteMap).some( - (item) => item.name === '@register-remotes/app2', - ), - ).toBe(false); - }); - - it('invokes bundler cache cleanup hook when unloading remote', () => { - const FM = new ModuleFederation({ - name: '@federation/instance-unload-bundler-cache', - version: '1.0.1', - remotes: [ - { - name: '@register-remotes/app2', - alias: 'app2', - entry: - 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', - }, - ], - }); - - const clearCache = vi.fn(); - (FM as any)[Symbol.for('mf_clear_bundler_remote_module_cache')] = - clearCache; - - expect(FM.unloadRemote('@register-remotes/app2')).toBe(true); - expect(clearCache).toHaveBeenCalledTimes(1); - expect(clearCache).toHaveBeenCalledWith( - expect.objectContaining({ - name: '@register-remotes/app2', - alias: 'app2', - }), - ); - }); }); diff --git a/packages/runtime-core/src/core.ts b/packages/runtime-core/src/core.ts index 4ab38ab06d5..56ab22aabab 100644 --- a/packages/runtime-core/src/core.ts +++ b/packages/runtime-core/src/core.ts @@ -345,10 +345,6 @@ export class ModuleFederation { return this.remoteHandler.registerRemotes(remotes, options); } - unloadRemote(nameOrAlias: string): boolean { - return this.remoteHandler.unloadRemote(nameOrAlias); - } - registerShared(shared: UserOptions['shared']) { this.sharedHandler.registerShared(this.options, { ...this.options, diff --git a/packages/runtime-core/src/remote/index.ts b/packages/runtime-core/src/remote/index.ts index 47cdeee0855..e3b719c9ca0 100644 --- a/packages/runtime-core/src/remote/index.ts +++ b/packages/runtime-core/src/remote/index.ts @@ -317,18 +317,6 @@ export class RemoteHandler { }); } - unloadRemote(nameOrAlias: string): boolean { - const { host } = this; - const remote = host.options.remotes.find( - (item) => item.name === nameOrAlias || item.alias === nameOrAlias, - ); - if (!remote) { - return false; - } - this.removeRemote(remote); - return true; - } - async getRemoteModuleAndOptions(options: { id: string }): Promise<{ module: Module; moduleOptions: ModuleOptions; @@ -487,14 +475,6 @@ export class RemoteHandler { const remoteInfo = loadedModule ? loadedModule.remoteInfo : getRemoteInfo(remote); - const clearBundlerRemoteModuleCache = ( - host as ModuleFederation & { - [key: symbol]: unknown; - } - )[Symbol.for('mf_clear_bundler_remote_module_cache')]; - if (typeof clearBundlerRemoteModuleCache === 'function') { - clearBundlerRemoteModuleCache(remote); - } if (remoteInfo.entry) { host.snapshotHandler.manifestCache.delete(remoteInfo.entry); diff --git a/packages/runtime/__tests__/api.spec.ts b/packages/runtime/__tests__/api.spec.ts index ba8333bc010..8214cd6c945 100644 --- a/packages/runtime/__tests__/api.spec.ts +++ b/packages/runtime/__tests__/api.spec.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi } from 'vitest'; -import { init, unloadRemote } from '../src'; +import { describe, it, expect } from 'vitest'; +import { init } from '../src'; // eslint-disable-next-line max-lines-per-function describe('api', () => { @@ -10,7 +10,6 @@ describe('api', () => { }); expect(FM.loadShare).not.toBe(null); expect(FM.loadRemote).not.toBe(null); - expect(FM.unloadRemote).not.toBe(null); }); it('initializes with the same name and returns the same instance', () => { const FM1 = init({ @@ -143,26 +142,4 @@ describe('api', () => { /The alias @scope of remote @scope\/component is not allowed to be the prefix of @federation\/button name or alias/, ); }); - - it('exports top-level unloadRemote wrapper and delegates to instance', async () => { - const FM = init({ - name: '@federation/unload-wrapper', - remotes: [ - { - name: '@register-remotes/app1', - alias: 'app1', - entry: - 'http://localhost:1111/resources/register-remotes/app1/federation-remote-entry.js', - }, - ], - }); - await FM.loadRemote('@register-remotes/app1/say'); - expect(unloadRemote('@register-remotes/app1')).toBe(true); - }); - - it('throws for top-level unloadRemote wrapper when instance is missing', async () => { - vi.resetModules(); - const runtime = await import('../src/index'); - expect(() => runtime.unloadRemote('missing')).toThrow(); - }); }); diff --git a/packages/runtime/__tests__/register-remotes.spec.ts b/packages/runtime/__tests__/register-remotes.spec.ts index 918391396cf..174d914c491 100644 --- a/packages/runtime/__tests__/register-remotes.spec.ts +++ b/packages/runtime/__tests__/register-remotes.spec.ts @@ -102,75 +102,4 @@ describe('ModuleFederation', () => { // Value is different from the registered remote expect(newApp1Res).toBe('hello app1 entry2'); }); - - it('unloads remote by name and supports re-registration', async () => { - const FM = new ModuleFederation({ - name: '@federation/runtime-unload-name', - version: '1.0.1', - remotes: [ - { - name: '@register-remotes/app2', - alias: 'app2', - entry: - 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', - }, - ], - }); - const app1Module = await FM.loadRemote string>>( - '@register-remotes/app2/say', - ); - assert(app1Module); - expect(await app1Module()).toBe('hello app2'); - - expect(FM.unloadRemote('@register-remotes/app2')).toBe(true); - await expect( - FM.loadRemote string>>('@register-remotes/app2/say'), - ).rejects.toThrow(); - - FM.registerRemotes([ - { - name: '@register-remotes/app2', - alias: 'app2', - entry: - 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', - }, - ]); - const app1ModuleReloaded = await FM.loadRemote string>>( - '@register-remotes/app2/say', - ); - assert(app1ModuleReloaded); - expect(await app1ModuleReloaded()).toBe('hello app2'); - }); - - it('unloads remote by alias', async () => { - const FM = new ModuleFederation({ - name: '@federation/runtime-unload-alias', - version: '1.0.1', - remotes: [ - { - name: '@register-remotes/app2', - alias: 'app2', - entry: - 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', - }, - ], - }); - const app1Module = await FM.loadRemote string>>('app2/say'); - assert(app1Module); - expect(await app1Module()).toBe('hello app2'); - - expect(FM.unloadRemote('app2')).toBe(true); - await expect( - FM.loadRemote string>>('app2/say'), - ).rejects.toThrow(); - }); - - it('returns false for missing remote unload', () => { - const FM = new ModuleFederation({ - name: '@federation/runtime-unload-miss', - version: '1.0.1', - remotes: [], - }); - expect(FM.unloadRemote('missing')).toBe(false); - }); }); diff --git a/packages/runtime/__tests__/unload.spec.ts b/packages/runtime/__tests__/unload.spec.ts new file mode 100644 index 00000000000..85406fca7b4 --- /dev/null +++ b/packages/runtime/__tests__/unload.spec.ts @@ -0,0 +1,232 @@ +import { assert, describe, expect, it, vi } from 'vitest'; +import { + CurrentGlobal, + Global, + ModuleFederation, +} from '@module-federation/runtime-core'; +import { init } from '../src'; +import { unloadRemote, unloadRemoteFromInstance } from '../src/unload'; + +describe('unload api', () => { + it('unloads loaded remote by name and allows re-registration', async () => { + const FM = new ModuleFederation({ + name: '@federation/runtime-unload-name', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + }, + ], + }); + const app1Module = await FM.loadRemote string>>( + '@register-remotes/app2/say', + ); + assert(app1Module); + expect(await app1Module()).toBe('hello app2'); + + expect(unloadRemoteFromInstance(FM, '@register-remotes/app2')).toBe(true); + expect( + FM.options.remotes.find((item) => item.name === '@register-remotes/app2'), + ).toBeUndefined(); + + await expect( + FM.loadRemote string>>('@register-remotes/app2/say'), + ).rejects.toThrow(); + + FM.registerRemotes([ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + }, + ]); + const app1ModuleReloaded = await FM.loadRemote string>>( + '@register-remotes/app2/say', + ); + assert(app1ModuleReloaded); + expect(await app1ModuleReloaded()).toBe('hello app2'); + }); + + it('unloads remote by alias', async () => { + const FM = new ModuleFederation({ + name: '@federation/runtime-unload-alias', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + }, + ], + }); + const app1Module = await FM.loadRemote string>>('app2/say'); + assert(app1Module); + expect(await app1Module()).toBe('hello app2'); + + expect(unloadRemoteFromInstance(FM, 'app2')).toBe(true); + await expect( + FM.loadRemote string>>('app2/say'), + ).rejects.toThrow(); + }); + + it('returns false for missing remote unload', () => { + const FM = new ModuleFederation({ + name: '@federation/runtime-unload-miss', + version: '1.0.1', + remotes: [], + }); + expect(unloadRemoteFromInstance(FM, 'missing')).toBe(false); + }); + + it('does not clear global container when unloading remote not loaded by current host', () => { + const entryGlobalName = '__TEST_RUNTIME_UNLOAD_NOT_LOADED__'; + try { + (CurrentGlobal as any)[entryGlobalName] = { loadedBy: 'other-host' }; + + const FM = new ModuleFederation({ + name: '@federation/runtime-unload-not-loaded', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + entryGlobalName, + }, + ], + }); + + expect(unloadRemoteFromInstance(FM, '@register-remotes/app2')).toBe(true); + expect((CurrentGlobal as any)[entryGlobalName]).toEqual({ + loadedBy: 'other-host', + }); + } finally { + delete (CurrentGlobal as any)[entryGlobalName]; + } + }); + + it('clears idToRemoteMap and preloaded entries for unloaded remote', async () => { + const FM = new ModuleFederation({ + name: '@federation/runtime-unload-id-map', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + }, + ], + }); + await FM.loadRemote string>>('@register-remotes/app2/say'); + await FM.loadRemote string>>('app2/say'); + Global.__FEDERATION__.__PRELOADED_MAP__.set('app2/say', true); + Global.__FEDERATION__.__PRELOADED_MAP__.set( + '@register-remotes/app2/say', + true, + ); + + expect( + Object.values((FM as any).remoteHandler.idToRemoteMap).some( + (item: any) => item.name === '@register-remotes/app2', + ), + ).toBe(true); + expect(unloadRemoteFromInstance(FM, '@register-remotes/app2')).toBe(true); + + expect( + Object.values((FM as any).remoteHandler.idToRemoteMap).some( + (item: any) => item.name === '@register-remotes/app2', + ), + ).toBe(false); + expect(Global.__FEDERATION__.__PRELOADED_MAP__.get('app2/say')).toBe( + undefined, + ); + expect( + Global.__FEDERATION__.__PRELOADED_MAP__.get('@register-remotes/app2/say'), + ).toBe(undefined); + }); + + it('clears webpack module cache and remote marker for unloaded remote only', () => { + const FM = new ModuleFederation({ + name: '@federation/runtime-unload-bundler-cache', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + }, + ], + }); + + const targetMapping: any = ['default', './say', 'external-target']; + targetMapping.p = Promise.resolve(1); + const untouchedMapping: any = ['default', './say', 'external-untouched']; + untouchedMapping.p = Promise.resolve(2); + + const webpackRequire: any = { + c: { + target: { id: 'target' }, + untouched: { id: 'untouched' }, + }, + m: { + target: () => null, + untouched: () => null, + }, + federation: { + bundlerRuntimeOptions: { + remotes: { + idToRemoteMap: { + target: [ + { externalType: 'script', name: '@register-remotes/app2' }, + ], + untouched: [{ externalType: 'script', name: 'other-remote' }], + }, + idToExternalAndNameMapping: { + target: targetMapping, + untouched: untouchedMapping, + }, + }, + }, + }, + }; + (FM as any)[Symbol.for('mf_webpack_require')] = webpackRequire; + + expect(unloadRemoteFromInstance(FM, '@register-remotes/app2')).toBe(true); + expect(webpackRequire.c.target).toBeUndefined(); + expect(webpackRequire.m.target).toBeUndefined(); + expect(targetMapping.p).toBeUndefined(); + expect(webpackRequire.c.untouched).toBeDefined(); + expect(webpackRequire.m.untouched).toBeDefined(); + expect(untouchedMapping.p).toBeDefined(); + }); + + it('exports top-level unloadRemote wrapper and delegates to instance', async () => { + const FM = init({ + name: '@federation/unload-wrapper', + remotes: [ + { + name: '@register-remotes/app1', + alias: 'app1', + entry: + 'http://localhost:1111/resources/register-remotes/app1/federation-remote-entry.js', + }, + ], + }); + await FM.loadRemote('@register-remotes/app1/say'); + expect(unloadRemote('@register-remotes/app1')).toBe(true); + }); + + it('throws for top-level unloadRemote wrapper when instance is missing', async () => { + vi.resetModules(); + const runtimeUnload = await import('../src/unload'); + expect(() => runtimeUnload.unloadRemote('missing')).toThrow(); + }); +}); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index aa0fe3083e5..e54fe7a402b 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -60,6 +60,16 @@ "default": "./dist/core.cjs.cjs" } }, + "./unload": { + "import": { + "types": "./dist/unload.d.ts", + "default": "./dist/unload.esm.js" + }, + "require": { + "types": "./dist/unload.d.ts", + "default": "./dist/unload.cjs.cjs" + } + }, "./*": "./*" }, "typesVersions": { @@ -75,6 +85,9 @@ ], "core": [ "./dist/core.d.ts" + ], + "unload": [ + "./dist/unload.d.ts" ] } }, diff --git a/packages/runtime/project.json b/packages/runtime/project.json index 2b910a5d0f7..d665d590e99 100644 --- a/packages/runtime/project.json +++ b/packages/runtime/project.json @@ -14,7 +14,8 @@ "main": "packages/runtime/src/index.ts", "additionalEntryPoints": [ "packages/runtime/src/types.ts", - "packages/runtime/src/helpers.ts" + "packages/runtime/src/helpers.ts", + "packages/runtime/src/unload.ts" ], "tsConfig": "packages/runtime/tsconfig.lib.json", "assets": [], diff --git a/packages/runtime/rollup.config.cjs b/packages/runtime/rollup.config.cjs index f3178adfaba..75be5dad74e 100644 --- a/packages/runtime/rollup.config.cjs +++ b/packages/runtime/rollup.config.cjs @@ -19,6 +19,7 @@ module.exports = (rollupConfig, projectOptions) => { types: 'packages/runtime/src/types.ts', helpers: 'packages/runtime/src/helpers.ts', core: 'packages/runtime/src/core.ts', + unload: 'packages/runtime/src/unload.ts', }; const pkg = require('./package.json'); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index c9cd6f4906e..d4253306f07 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -101,14 +101,6 @@ export function registerRemotes( return FederationInstance.registerRemotes.apply(FederationInstance, args); } -export function unloadRemote( - ...args: Parameters -): ReturnType { - assert(FederationInstance, getShortErrorMsg(RUNTIME_009, runtimeDescMap)); - // eslint-disable-next-line prefer-spread - return FederationInstance.unloadRemote.apply(FederationInstance, args); -} - export function registerPlugins( ...args: Parameters ): ReturnType { diff --git a/packages/runtime/src/unload.ts b/packages/runtime/src/unload.ts new file mode 100644 index 00000000000..82d117ef8e1 --- /dev/null +++ b/packages/runtime/src/unload.ts @@ -0,0 +1,204 @@ +import { + getShortErrorMsg, + RUNTIME_009, + runtimeDescMap, +} from '@module-federation/error-codes'; +import { decodeName, ENCODE_NAME_PREFIX } from '@module-federation/sdk'; +import { + assert, + getInfoWithoutType, + getRemoteInfo, + Global, + type ModuleFederation, +} from '@module-federation/runtime-core'; +import helpers from './helpers'; +import { getInstance } from './index'; + +type RuntimeRemote = ModuleFederation['options']['remotes'][number]; + +function clearBundlerRemoteModuleCache( + instance: ModuleFederation, + remote: RuntimeRemote, +): void { + const hostWithInternal = instance as ModuleFederation & { + [key: symbol]: unknown; + }; + const webpackRequire = hostWithInternal[Symbol.for('mf_webpack_require')] as + | { + c: Record; + m: Record; + federation?: { + bundlerRuntimeOptions?: { + remotes?: { + idToRemoteMap?: Record>; + idToExternalAndNameMapping?: Record; + }; + }; + }; + } + | undefined; + if (!webpackRequire) { + return; + } + const remotesOptions = + webpackRequire?.federation?.bundlerRuntimeOptions?.remotes; + if (!remotesOptions) { + return; + } + + const { idToRemoteMap = {}, idToExternalAndNameMapping = {} } = + remotesOptions; + const candidates = new Set( + [remote.name, remote.alias].filter(Boolean) as string[], + ); + if (!candidates.size) { + return; + } + + const normalized = (value: string): string => { + try { + return decodeName(value, ENCODE_NAME_PREFIX); + } catch { + return value; + } + }; + + Object.entries(idToRemoteMap).forEach(([moduleId, remoteInfos]) => { + if (!Array.isArray(remoteInfos)) { + return; + } + const matched = remoteInfos.some((remoteInfo) => { + if (!remoteInfo?.name) { + return false; + } + const remoteName = remoteInfo.name; + return ( + candidates.has(remoteName) || candidates.has(normalized(remoteName)) + ); + }); + if (!matched) { + return; + } + + delete webpackRequire.c[moduleId]; + delete webpackRequire.m[moduleId]; + const mappingItem = idToExternalAndNameMapping[moduleId]; + if (mappingItem && typeof mappingItem === 'object' && 'p' in mappingItem) { + delete mappingItem.p; + } + }); +} + +function clearHostSnapshotAndManifestLoading( + instance: ModuleFederation, + remote: RuntimeRemote, +): void { + const hostGlobalSnapshot = helpers.global.getGlobalSnapshotInfoByModuleInfo({ + name: instance.name, + version: instance.options.version, + }); + if ( + !hostGlobalSnapshot || + !('remotesInfo' in hostGlobalSnapshot) || + !hostGlobalSnapshot.remotesInfo + ) { + return; + } + + const remoteKey = getInfoWithoutType( + hostGlobalSnapshot.remotesInfo, + remote.name, + ).key; + if (!remoteKey) { + return; + } + delete hostGlobalSnapshot.remotesInfo[remoteKey]; + + if ( + // eslint-disable-next-line no-extra-boolean-cast + Boolean(Global.__FEDERATION__.__MANIFEST_LOADING__[remoteKey]) + ) { + delete Global.__FEDERATION__.__MANIFEST_LOADING__[remoteKey]; + } +} + +function clearRemoteBookkeeping( + instance: ModuleFederation, + remote: RuntimeRemote, +): void { + const remoteIndex = instance.options.remotes.findIndex( + (item) => item.name === remote.name, + ); + if (remoteIndex !== -1) { + instance.options.remotes.splice(remoteIndex, 1); + } + + const remoteInfo = getRemoteInfo(remote); + if (remoteInfo.entry) { + instance.snapshotHandler.manifestCache.delete(remoteInfo.entry); + } + clearHostSnapshotAndManifestLoading(instance, remote); + + instance.moduleCache.delete(remote.name); + + const remoteHandler = (instance as any).remoteHandler as + | { + idToRemoteMap?: Record; + } + | undefined; + if (remoteHandler?.idToRemoteMap) { + Object.keys(remoteHandler.idToRemoteMap).forEach((id) => { + if (remoteHandler.idToRemoteMap?.[id]?.name === remote.name) { + delete remoteHandler.idToRemoteMap[id]; + } + }); + } + + const remotePrefixes = [remote.name, remote.alias].filter( + Boolean, + ) as string[]; + const preloadedMap = Global.__FEDERATION__.__PRELOADED_MAP__; + Array.from(preloadedMap.keys()).forEach((key) => { + if ( + remotePrefixes.some( + (prefix) => key === prefix || key.startsWith(`${prefix}/`), + ) + ) { + preloadedMap.delete(key); + } + }); +} + +export function unloadRemoteFromInstance( + instance: ModuleFederation, + nameOrAlias: string, +): boolean { + const remote = instance.options.remotes.find( + (item) => item.name === nameOrAlias || item.alias === nameOrAlias, + ); + if (!remote) { + return false; + } + + clearBundlerRemoteModuleCache(instance, remote); + + const remoteHandler = (instance as any).remoteHandler as + | { + removeRemote?: (remote: RuntimeRemote) => void; + } + | undefined; + const loadedByCurrentHost = instance.moduleCache.has(remote.name); + if (loadedByCurrentHost && remoteHandler?.removeRemote) { + remoteHandler.removeRemote(remote); + return true; + } + + clearRemoteBookkeeping(instance, remote); + return true; +} + +export function unloadRemote(nameOrAlias: string): boolean { + const instance = getInstance(); + assert(instance, getShortErrorMsg(RUNTIME_009, runtimeDescMap)); + return unloadRemoteFromInstance(instance, nameOrAlias); +} diff --git a/packages/webpack-bundler-runtime/__tests__/init.spec.ts b/packages/webpack-bundler-runtime/__tests__/init.spec.ts index 137a82638c0..d7d7b79598b 100644 --- a/packages/webpack-bundler-runtime/__tests__/init.spec.ts +++ b/packages/webpack-bundler-runtime/__tests__/init.spec.ts @@ -10,24 +10,11 @@ describe('init', () => { jest.clearAllMocks(); }); - test('attaches internal symbols and clears matched remote module cache', () => { + test('attaches webpackRequire to runtime instance using internal symbol', () => { const instance = {}; (runtimeInit as jest.Mock).mockReturnValue(instance); - const targetMapping: any = ['default', './say', 'external-target']; - targetMapping.p = Promise.resolve(1); - const untouchedMapping: any = ['default', './say', 'external-untouched']; - untouchedMapping.p = Promise.resolve(2); - const webpackRequire: any = { - c: { - target: { id: 'target' }, - untouched: { id: 'untouched' }, - }, - m: { - target: () => null, - untouched: () => null, - }, federation: { initOptions: { name: 'host', @@ -40,20 +27,6 @@ describe('init', () => { bundlerRuntime: { getSharedFallbackGetter: jest.fn(), }, - bundlerRuntimeOptions: { - remotes: { - idToRemoteMap: { - target: [ - { externalType: 'script', name: '@register-remotes/app2' }, - ], - untouched: [{ externalType: 'script', name: 'other-remote' }], - }, - idToExternalAndNameMapping: { - target: targetMapping, - untouched: untouchedMapping, - }, - }, - }, libraryType: 'var', }, }; @@ -64,19 +37,6 @@ describe('init', () => { expect( (instance as Record)[Symbol.for('mf_webpack_require')], ).toBe(webpackRequire); - - const cleanup = (instance as Record)[ - Symbol.for('mf_clear_bundler_remote_module_cache') - ] as (remote: { name: string; alias?: string }) => void; - expect(typeof cleanup).toBe('function'); - cleanup({ name: '@register-remotes/app2', alias: 'app2' }); - - expect(webpackRequire.c.target).toBeUndefined(); - expect(webpackRequire.m.target).toBeUndefined(); - expect(targetMapping.p).toBeUndefined(); - expect(webpackRequire.c.untouched).toBeDefined(); - expect(webpackRequire.m.untouched).toBeDefined(); - expect(untouchedMapping.p).toBeDefined(); }); test('preserves init behavior while appending tree-shake plugin', () => { diff --git a/packages/webpack-bundler-runtime/src/init.ts b/packages/webpack-bundler-runtime/src/init.ts index ece9c28bed2..688e86dbb77 100644 --- a/packages/webpack-bundler-runtime/src/init.ts +++ b/packages/webpack-bundler-runtime/src/init.ts @@ -3,71 +3,10 @@ import { getRemoteEntry, type ModuleFederationRuntimePlugin, } from '@module-federation/runtime'; -import type { ShareArgs, Remote } from '@module-federation/runtime/types'; +import type { ShareArgs } from '@module-federation/runtime/types'; import helpers from '@module-federation/runtime/helpers'; -import { decodeName, ENCODE_NAME_PREFIX } from '@module-federation/sdk'; const WEBPACK_REQUIRE_SYMBOL = Symbol.for('mf_webpack_require'); -const CLEAR_BUNDLER_REMOTE_MODULE_CACHE_SYMBOL = Symbol.for( - 'mf_clear_bundler_remote_module_cache', -); - -function clearBundlerRemoteModuleCache( - webpackRequire: WebpackRequire, - remote: Pick, -): void { - const remotesOptions = - webpackRequire?.federation?.bundlerRuntimeOptions?.remotes; - if (!remotesOptions) { - return; - } - - const { idToRemoteMap = {}, idToExternalAndNameMapping = {} } = - remotesOptions as { - idToRemoteMap?: Record>; - idToExternalAndNameMapping?: Record; - }; - - const candidates = new Set( - [remote.name, remote.alias].filter(Boolean) as string[], - ); - if (!candidates.size) { - return; - } - - const normalized = (value: string) => { - try { - return decodeName(value, ENCODE_NAME_PREFIX); - } catch { - return value; - } - }; - - Object.entries(idToRemoteMap).forEach(([moduleId, remoteInfos]) => { - if (!Array.isArray(remoteInfos)) { - return; - } - const matched = remoteInfos.some((remoteInfo) => { - if (!remoteInfo?.name) { - return false; - } - const remoteName = remoteInfo.name; - return ( - candidates.has(remoteName) || candidates.has(normalized(remoteName)) - ); - }); - if (!matched) { - return; - } - - delete webpackRequire.c[moduleId]; - delete webpackRequire.m[moduleId]; - const mappingItem = idToExternalAndNameMapping[moduleId]; - if (mappingItem && typeof mappingItem === 'object' && 'p' in mappingItem) { - delete mappingItem.p; - } - }); -} export function init({ webpackRequire }: { webpackRequire: WebpackRequire }) { const { initOptions, runtime, sharedFallback, bundlerRuntime, libraryType } = @@ -192,9 +131,5 @@ export function init({ webpackRequire }: { webpackRequire: WebpackRequire }) { const instance = runtime!.init(initOptions); (instance as unknown as Record)[WEBPACK_REQUIRE_SYMBOL] = webpackRequire; - (instance as unknown as Record)[ - CLEAR_BUNDLER_REMOTE_MODULE_CACHE_SYMBOL - ] = (remote: Pick) => - clearBundlerRemoteModuleCache(webpackRequire, remote); return instance; } From a9a123a1a388425873a717ac3e15a104d45beaa4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 8 Feb 2026 13:38:21 -0800 Subject: [PATCH 07/13] refactor(runtime): move unload internals to runtime-core --- packages/runtime-core/src/core.ts | 4 + packages/runtime-core/src/remote/index.ts | 130 ++++++++++++--- packages/runtime/src/unload.ts | 188 +--------------------- 3 files changed, 118 insertions(+), 204 deletions(-) diff --git a/packages/runtime-core/src/core.ts b/packages/runtime-core/src/core.ts index 56ab22aabab..4ab38ab06d5 100644 --- a/packages/runtime-core/src/core.ts +++ b/packages/runtime-core/src/core.ts @@ -345,6 +345,10 @@ export class ModuleFederation { return this.remoteHandler.registerRemotes(remotes, options); } + unloadRemote(nameOrAlias: string): boolean { + return this.remoteHandler.unloadRemote(nameOrAlias); + } + registerShared(shared: UserOptions['shared']) { this.sharedHandler.registerShared(this.options, { ...this.options, diff --git a/packages/runtime-core/src/remote/index.ts b/packages/runtime-core/src/remote/index.ts index e3b719c9ca0..5a8731aef94 100644 --- a/packages/runtime-core/src/remote/index.ts +++ b/packages/runtime-core/src/remote/index.ts @@ -4,6 +4,8 @@ import { composeKeyWithSeparator, ModuleInfo, GlobalModuleInfo, + decodeName, + ENCODE_NAME_PREFIX, } from '@module-federation/sdk'; import { getShortErrorMsg, @@ -169,6 +171,85 @@ export class RemoteHandler { this.idToRemoteMap = {}; } + private clearBundlerRemoteModuleCache(remote: Remote): void { + const hostWithInternal = this.host as ModuleFederation & { + [key: symbol]: unknown; + }; + const webpackRequire = hostWithInternal[ + Symbol.for('mf_webpack_require') + ] as + | { + c?: Record; + m?: Record; + federation?: { + bundlerRuntimeOptions?: { + remotes?: { + idToRemoteMap?: Record>; + idToExternalAndNameMapping?: Record; + }; + }; + }; + } + | undefined; + const remotesOptions = + webpackRequire?.federation?.bundlerRuntimeOptions?.remotes; + if (!remotesOptions) { + return; + } + + const { idToRemoteMap = {}, idToExternalAndNameMapping = {} } = + remotesOptions; + const candidates = new Set( + [remote.name, remote.alias].filter(Boolean) as string[], + ); + if (!candidates.size) { + return; + } + + const normalizeName = (value: string): string => { + try { + return decodeName(value, ENCODE_NAME_PREFIX); + } catch { + return value; + } + }; + + Object.entries(idToRemoteMap).forEach(([moduleId, remoteInfos]) => { + if (!Array.isArray(remoteInfos)) { + return; + } + + const matched = remoteInfos.some((remoteInfo) => { + if (!remoteInfo?.name) { + return false; + } + const remoteName = remoteInfo.name; + return ( + candidates.has(remoteName) || + candidates.has(normalizeName(remoteName)) + ); + }); + if (!matched) { + return; + } + + if (webpackRequire.c) { + delete webpackRequire.c[moduleId]; + } + if (webpackRequire.m) { + delete webpackRequire.m[moduleId]; + } + const mappingItem = idToExternalAndNameMapping[moduleId]; + if ( + mappingItem && + typeof mappingItem === 'object' && + 'p' in mappingItem + ) { + delete mappingItem.p; + } + }); + } + formatAndRegisterRemote(globalOptions: Options, userOptions: UserOptions) { const userRemotes = userOptions.remotes || []; @@ -194,6 +275,17 @@ export class RemoteHandler { } } + unloadRemote(nameOrAlias: string): boolean { + const remote = this.host.options.remotes.find( + (item) => item.name === nameOrAlias || item.alias === nameOrAlias, + ); + if (!remote) { + return false; + } + this.removeRemote(remote); + return true; + } + // eslint-disable-next-line max-lines-per-function // eslint-disable-next-line @typescript-eslint/member-ordering async loadRemote( @@ -578,24 +670,6 @@ export class RemoteHandler { ); CurrentGlobal.__FEDERATION__.__INSTANCES__.splice(remoteInsIndex, 1); } - - const { hostGlobalSnapshot } = getGlobalRemoteInfo(remote, host); - if (hostGlobalSnapshot) { - const remoteKey = - hostGlobalSnapshot && - 'remotesInfo' in hostGlobalSnapshot && - hostGlobalSnapshot.remotesInfo && - getInfoWithoutType(hostGlobalSnapshot.remotesInfo, remote.name).key; - if (remoteKey) { - delete hostGlobalSnapshot.remotesInfo[remoteKey]; - if ( - //eslint-disable-next-line no-extra-boolean-cast - Boolean(Global.__FEDERATION__.__MANIFEST_LOADING__[remoteKey]) - ) { - delete Global.__FEDERATION__.__MANIFEST_LOADING__[remoteKey]; - } - } - } } host.moduleCache.delete(remote.name); @@ -618,6 +692,26 @@ export class RemoteHandler { preloadedMap.delete(key); } }); + + const { hostGlobalSnapshot } = getGlobalRemoteInfo(remote, host); + if (hostGlobalSnapshot) { + const remoteKey = + hostGlobalSnapshot && + 'remotesInfo' in hostGlobalSnapshot && + hostGlobalSnapshot.remotesInfo && + getInfoWithoutType(hostGlobalSnapshot.remotesInfo, remote.name).key; + if (remoteKey) { + delete hostGlobalSnapshot.remotesInfo[remoteKey]; + if ( + //eslint-disable-next-line no-extra-boolean-cast + Boolean(Global.__FEDERATION__.__MANIFEST_LOADING__[remoteKey]) + ) { + delete Global.__FEDERATION__.__MANIFEST_LOADING__[remoteKey]; + } + } + } + + this.clearBundlerRemoteModuleCache(remote); } catch (err) { logger.log('removeRemote fail: ', err); } diff --git a/packages/runtime/src/unload.ts b/packages/runtime/src/unload.ts index 82d117ef8e1..e7de8817951 100644 --- a/packages/runtime/src/unload.ts +++ b/packages/runtime/src/unload.ts @@ -3,198 +3,14 @@ import { RUNTIME_009, runtimeDescMap, } from '@module-federation/error-codes'; -import { decodeName, ENCODE_NAME_PREFIX } from '@module-federation/sdk'; -import { - assert, - getInfoWithoutType, - getRemoteInfo, - Global, - type ModuleFederation, -} from '@module-federation/runtime-core'; -import helpers from './helpers'; +import { assert, type ModuleFederation } from '@module-federation/runtime-core'; import { getInstance } from './index'; -type RuntimeRemote = ModuleFederation['options']['remotes'][number]; - -function clearBundlerRemoteModuleCache( - instance: ModuleFederation, - remote: RuntimeRemote, -): void { - const hostWithInternal = instance as ModuleFederation & { - [key: symbol]: unknown; - }; - const webpackRequire = hostWithInternal[Symbol.for('mf_webpack_require')] as - | { - c: Record; - m: Record; - federation?: { - bundlerRuntimeOptions?: { - remotes?: { - idToRemoteMap?: Record>; - idToExternalAndNameMapping?: Record; - }; - }; - }; - } - | undefined; - if (!webpackRequire) { - return; - } - const remotesOptions = - webpackRequire?.federation?.bundlerRuntimeOptions?.remotes; - if (!remotesOptions) { - return; - } - - const { idToRemoteMap = {}, idToExternalAndNameMapping = {} } = - remotesOptions; - const candidates = new Set( - [remote.name, remote.alias].filter(Boolean) as string[], - ); - if (!candidates.size) { - return; - } - - const normalized = (value: string): string => { - try { - return decodeName(value, ENCODE_NAME_PREFIX); - } catch { - return value; - } - }; - - Object.entries(idToRemoteMap).forEach(([moduleId, remoteInfos]) => { - if (!Array.isArray(remoteInfos)) { - return; - } - const matched = remoteInfos.some((remoteInfo) => { - if (!remoteInfo?.name) { - return false; - } - const remoteName = remoteInfo.name; - return ( - candidates.has(remoteName) || candidates.has(normalized(remoteName)) - ); - }); - if (!matched) { - return; - } - - delete webpackRequire.c[moduleId]; - delete webpackRequire.m[moduleId]; - const mappingItem = idToExternalAndNameMapping[moduleId]; - if (mappingItem && typeof mappingItem === 'object' && 'p' in mappingItem) { - delete mappingItem.p; - } - }); -} - -function clearHostSnapshotAndManifestLoading( - instance: ModuleFederation, - remote: RuntimeRemote, -): void { - const hostGlobalSnapshot = helpers.global.getGlobalSnapshotInfoByModuleInfo({ - name: instance.name, - version: instance.options.version, - }); - if ( - !hostGlobalSnapshot || - !('remotesInfo' in hostGlobalSnapshot) || - !hostGlobalSnapshot.remotesInfo - ) { - return; - } - - const remoteKey = getInfoWithoutType( - hostGlobalSnapshot.remotesInfo, - remote.name, - ).key; - if (!remoteKey) { - return; - } - delete hostGlobalSnapshot.remotesInfo[remoteKey]; - - if ( - // eslint-disable-next-line no-extra-boolean-cast - Boolean(Global.__FEDERATION__.__MANIFEST_LOADING__[remoteKey]) - ) { - delete Global.__FEDERATION__.__MANIFEST_LOADING__[remoteKey]; - } -} - -function clearRemoteBookkeeping( - instance: ModuleFederation, - remote: RuntimeRemote, -): void { - const remoteIndex = instance.options.remotes.findIndex( - (item) => item.name === remote.name, - ); - if (remoteIndex !== -1) { - instance.options.remotes.splice(remoteIndex, 1); - } - - const remoteInfo = getRemoteInfo(remote); - if (remoteInfo.entry) { - instance.snapshotHandler.manifestCache.delete(remoteInfo.entry); - } - clearHostSnapshotAndManifestLoading(instance, remote); - - instance.moduleCache.delete(remote.name); - - const remoteHandler = (instance as any).remoteHandler as - | { - idToRemoteMap?: Record; - } - | undefined; - if (remoteHandler?.idToRemoteMap) { - Object.keys(remoteHandler.idToRemoteMap).forEach((id) => { - if (remoteHandler.idToRemoteMap?.[id]?.name === remote.name) { - delete remoteHandler.idToRemoteMap[id]; - } - }); - } - - const remotePrefixes = [remote.name, remote.alias].filter( - Boolean, - ) as string[]; - const preloadedMap = Global.__FEDERATION__.__PRELOADED_MAP__; - Array.from(preloadedMap.keys()).forEach((key) => { - if ( - remotePrefixes.some( - (prefix) => key === prefix || key.startsWith(`${prefix}/`), - ) - ) { - preloadedMap.delete(key); - } - }); -} - export function unloadRemoteFromInstance( instance: ModuleFederation, nameOrAlias: string, ): boolean { - const remote = instance.options.remotes.find( - (item) => item.name === nameOrAlias || item.alias === nameOrAlias, - ); - if (!remote) { - return false; - } - - clearBundlerRemoteModuleCache(instance, remote); - - const remoteHandler = (instance as any).remoteHandler as - | { - removeRemote?: (remote: RuntimeRemote) => void; - } - | undefined; - const loadedByCurrentHost = instance.moduleCache.has(remote.name); - if (loadedByCurrentHost && remoteHandler?.removeRemote) { - remoteHandler.removeRemote(remote); - return true; - } - - clearRemoteBookkeeping(instance, remote); - return true; + return instance.unloadRemote(nameOrAlias); } export function unloadRemote(nameOrAlias: string): boolean { From 916fa2abe5f096e30bf4606b00efc5f198338b00 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Feb 2026 20:37:36 -0800 Subject: [PATCH 08/13] Bundler runtime as plugin (#4393) Co-authored-by: Cursor Agent --- packages/runtime-core/src/remote/index.ts | 96 +++---------------- .../src/webpack-bundler-runtime.ts | 1 + packages/runtime/__tests__/unload.spec.ts | 54 +++-------- packages/webpack-bundler-runtime/README.md | 18 ++++ .../__tests__/init.spec.ts | 47 ++++++++- .../__tests__/unload-remote-plugin.spec.ts | 62 ++++++++++++ packages/webpack-bundler-runtime/src/index.ts | 2 + packages/webpack-bundler-runtime/src/init.ts | 10 +- .../src/unload-remote-plugin.ts | 93 ++++++++++++++++++ 9 files changed, 258 insertions(+), 125 deletions(-) create mode 100644 packages/webpack-bundler-runtime/__tests__/unload-remote-plugin.spec.ts create mode 100644 packages/webpack-bundler-runtime/src/unload-remote-plugin.ts diff --git a/packages/runtime-core/src/remote/index.ts b/packages/runtime-core/src/remote/index.ts index 5a8731aef94..fe83cd1b762 100644 --- a/packages/runtime-core/src/remote/index.ts +++ b/packages/runtime-core/src/remote/index.ts @@ -4,8 +4,6 @@ import { composeKeyWithSeparator, ModuleInfo, GlobalModuleInfo, - decodeName, - ENCODE_NAME_PREFIX, } from '@module-federation/sdk'; import { getShortErrorMsg, @@ -74,6 +72,15 @@ export class RemoteHandler { remote: Remote; origin: ModuleFederation; }>('registerRemote'), + afterRemoveRemote: new SyncHook< + [ + { + remote: Remote; + origin: ModuleFederation; + }, + ], + void + >('afterRemoveRemote'), beforeRequest: new AsyncWaterfallHook<{ id: string; options: Options; @@ -171,85 +178,6 @@ export class RemoteHandler { this.idToRemoteMap = {}; } - private clearBundlerRemoteModuleCache(remote: Remote): void { - const hostWithInternal = this.host as ModuleFederation & { - [key: symbol]: unknown; - }; - const webpackRequire = hostWithInternal[ - Symbol.for('mf_webpack_require') - ] as - | { - c?: Record; - m?: Record; - federation?: { - bundlerRuntimeOptions?: { - remotes?: { - idToRemoteMap?: Record>; - idToExternalAndNameMapping?: Record; - }; - }; - }; - } - | undefined; - const remotesOptions = - webpackRequire?.federation?.bundlerRuntimeOptions?.remotes; - if (!remotesOptions) { - return; - } - - const { idToRemoteMap = {}, idToExternalAndNameMapping = {} } = - remotesOptions; - const candidates = new Set( - [remote.name, remote.alias].filter(Boolean) as string[], - ); - if (!candidates.size) { - return; - } - - const normalizeName = (value: string): string => { - try { - return decodeName(value, ENCODE_NAME_PREFIX); - } catch { - return value; - } - }; - - Object.entries(idToRemoteMap).forEach(([moduleId, remoteInfos]) => { - if (!Array.isArray(remoteInfos)) { - return; - } - - const matched = remoteInfos.some((remoteInfo) => { - if (!remoteInfo?.name) { - return false; - } - const remoteName = remoteInfo.name; - return ( - candidates.has(remoteName) || - candidates.has(normalizeName(remoteName)) - ); - }); - if (!matched) { - return; - } - - if (webpackRequire.c) { - delete webpackRequire.c[moduleId]; - } - if (webpackRequire.m) { - delete webpackRequire.m[moduleId]; - } - const mappingItem = idToExternalAndNameMapping[moduleId]; - if ( - mappingItem && - typeof mappingItem === 'object' && - 'p' in mappingItem - ) { - delete mappingItem.p; - } - }); - } - formatAndRegisterRemote(globalOptions: Options, userOptions: UserOptions) { const userRemotes = userOptions.remotes || []; @@ -710,8 +638,10 @@ export class RemoteHandler { } } } - - this.clearBundlerRemoteModuleCache(remote); + this.hooks.lifecycle.afterRemoveRemote.emit({ + remote, + origin: host, + }); } catch (err) { logger.log('removeRemote fail: ', err); } diff --git a/packages/runtime-tools/src/webpack-bundler-runtime.ts b/packages/runtime-tools/src/webpack-bundler-runtime.ts index 7ada0a527ca..b0edfc3de56 100644 --- a/packages/runtime-tools/src/webpack-bundler-runtime.ts +++ b/packages/runtime-tools/src/webpack-bundler-runtime.ts @@ -1 +1,2 @@ export { default } from '@module-federation/webpack-bundler-runtime'; +export * from '@module-federation/webpack-bundler-runtime'; diff --git a/packages/runtime/__tests__/unload.spec.ts b/packages/runtime/__tests__/unload.spec.ts index 85406fca7b4..fd17a0b5b60 100644 --- a/packages/runtime/__tests__/unload.spec.ts +++ b/packages/runtime/__tests__/unload.spec.ts @@ -152,7 +152,8 @@ describe('unload api', () => { ).toBe(undefined); }); - it('clears webpack module cache and remote marker for unloaded remote only', () => { + it('emits afterRemoveRemote hook on unload', () => { + const afterRemoveRemote = vi.fn(); const FM = new ModuleFederation({ name: '@federation/runtime-unload-bundler-cache', version: '1.0.1', @@ -165,47 +166,22 @@ describe('unload api', () => { }, ], }); - - const targetMapping: any = ['default', './say', 'external-target']; - targetMapping.p = Promise.resolve(1); - const untouchedMapping: any = ['default', './say', 'external-untouched']; - untouchedMapping.p = Promise.resolve(2); - - const webpackRequire: any = { - c: { - target: { id: 'target' }, - untouched: { id: 'untouched' }, - }, - m: { - target: () => null, - untouched: () => null, - }, - federation: { - bundlerRuntimeOptions: { - remotes: { - idToRemoteMap: { - target: [ - { externalType: 'script', name: '@register-remotes/app2' }, - ], - untouched: [{ externalType: 'script', name: 'other-remote' }], - }, - idToExternalAndNameMapping: { - target: targetMapping, - untouched: untouchedMapping, - }, - }, - }, + FM.registerPlugins([ + { + name: 'after-remove-remote-test', + afterRemoveRemote, }, - }; - (FM as any)[Symbol.for('mf_webpack_require')] = webpackRequire; + ]); expect(unloadRemoteFromInstance(FM, '@register-remotes/app2')).toBe(true); - expect(webpackRequire.c.target).toBeUndefined(); - expect(webpackRequire.m.target).toBeUndefined(); - expect(targetMapping.p).toBeUndefined(); - expect(webpackRequire.c.untouched).toBeDefined(); - expect(webpackRequire.m.untouched).toBeDefined(); - expect(untouchedMapping.p).toBeDefined(); + expect(afterRemoveRemote).toHaveBeenCalledTimes(1); + expect(afterRemoveRemote).toHaveBeenCalledWith({ + remote: expect.objectContaining({ + name: '@register-remotes/app2', + alias: 'app2', + }), + origin: FM, + }); }); it('exports top-level unloadRemote wrapper and delegates to instance', async () => { diff --git a/packages/webpack-bundler-runtime/README.md b/packages/webpack-bundler-runtime/README.md index b82a0435544..02222a6beb3 100644 --- a/packages/webpack-bundler-runtime/README.md +++ b/packages/webpack-bundler-runtime/README.md @@ -19,3 +19,21 @@ __webpack_require__.federation = federation; __webpack_require__.f.remotes = __webpack_require__.federation.remotes(options); __webpack_require__.f.consumes = __webpack_require__.federation.remotes(options); ``` + +## Runtime plugins + +Webpack-specific behaviors (like clearing webpack's module cache on unload) +are exposed as runtime plugins. The bundler runtime automatically registers +the unload plugin during init, but you can also register it manually: + +```ts +import { unloadRemotePlugin } from '@module-federation/webpack-bundler-runtime'; + +const instance = federation.runtime.init({ + name: 'host', + remotes: [], +}); + +instance.registerPlugins([unloadRemotePlugin()]); +instance.unloadRemote('remote/subpath'); +``` diff --git a/packages/webpack-bundler-runtime/__tests__/init.spec.ts b/packages/webpack-bundler-runtime/__tests__/init.spec.ts index d7d7b79598b..c32f78a4e67 100644 --- a/packages/webpack-bundler-runtime/__tests__/init.spec.ts +++ b/packages/webpack-bundler-runtime/__tests__/init.spec.ts @@ -39,7 +39,7 @@ describe('init', () => { ).toBe(webpackRequire); }); - test('preserves init behavior while appending tree-shake plugin', () => { + test('preserves init behavior while appending runtime plugins', () => { const instance = { foo: 'bar' }; (runtimeInit as jest.Mock).mockReturnValue(instance); @@ -72,7 +72,50 @@ describe('init', () => { expect(runtimeInit).toHaveBeenCalledTimes(1); const calledOptions = (runtimeInit as jest.Mock).mock.calls[0][0]; expect(calledOptions.plugins[0]).toBe(existingPlugin); - expect(calledOptions.plugins).toHaveLength(2); + expect(calledOptions.plugins).toHaveLength(3); expect(typeof calledOptions.plugins[1].beforeInit).toBe('function'); + expect(calledOptions.plugins[2].name).toBe('unload-remote-plugin'); + }); + + test('does not append unload-remote plugin when already provided', () => { + const instance = { foo: 'bar' }; + (runtimeInit as jest.Mock).mockReturnValue(instance); + + const existingPlugin = { + name: 'existing-plugin', + beforeInit: jest.fn((args) => args), + }; + const unloadRemotePlugin = { + name: 'unload-remote-plugin', + afterRemoveRemote: jest.fn(), + }; + + const webpackRequire: any = { + federation: { + initOptions: { + name: 'host', + remotes: [], + plugins: [existingPlugin, unloadRemotePlugin], + }, + runtime: { + init: runtimeInit, + }, + sharedFallback: undefined, + bundlerRuntime: { + getSharedFallbackGetter: jest.fn(), + }, + libraryType: 'var', + }, + }; + + const result = init({ webpackRequire }); + + expect(result).toBe(instance); + expect(runtimeInit).toHaveBeenCalledTimes(1); + const calledOptions = (runtimeInit as jest.Mock).mock.calls[0][0]; + expect(calledOptions.plugins).toHaveLength(3); + expect(calledOptions.plugins[0]).toBe(existingPlugin); + expect(calledOptions.plugins[1]).toBe(unloadRemotePlugin); + expect(typeof calledOptions.plugins[2].beforeInit).toBe('function'); }); }); diff --git a/packages/webpack-bundler-runtime/__tests__/unload-remote-plugin.spec.ts b/packages/webpack-bundler-runtime/__tests__/unload-remote-plugin.spec.ts new file mode 100644 index 00000000000..bcd56de07d9 --- /dev/null +++ b/packages/webpack-bundler-runtime/__tests__/unload-remote-plugin.spec.ts @@ -0,0 +1,62 @@ +import { ModuleFederation } from '@module-federation/runtime-core'; +import { unloadRemotePlugin } from '../src/unload-remote-plugin'; + +describe('unloadRemotePlugin', () => { + test('clears webpack module cache and remote marker for unloaded remote only', () => { + const FM = new ModuleFederation({ + name: '@federation/runtime-unload-bundler-cache', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + }, + ], + }); + + FM.registerPlugins([unloadRemotePlugin()]); + + const targetMapping: any = ['default', './say', 'external-target']; + targetMapping.p = Promise.resolve(1); + const untouchedMapping: any = ['default', './say', 'external-untouched']; + untouchedMapping.p = Promise.resolve(2); + + const webpackRequire: any = { + c: { + target: { id: 'target' }, + untouched: { id: 'untouched' }, + }, + m: { + target: () => null, + untouched: () => null, + }, + federation: { + bundlerRuntimeOptions: { + remotes: { + idToRemoteMap: { + target: [ + { externalType: 'script', name: '@register-remotes/app2' }, + ], + untouched: [{ externalType: 'script', name: 'other-remote' }], + }, + idToExternalAndNameMapping: { + target: targetMapping, + untouched: untouchedMapping, + }, + }, + }, + }, + }; + (FM as any)[Symbol.for('mf_webpack_require')] = webpackRequire; + + expect(FM.unloadRemote('@register-remotes/app2')).toBe(true); + expect(webpackRequire.c.target).toBeUndefined(); + expect(webpackRequire.m.target).toBeUndefined(); + expect(targetMapping.p).toBeUndefined(); + expect(webpackRequire.c.untouched).toBeDefined(); + expect(webpackRequire.m.untouched).toBeDefined(); + expect(untouchedMapping.p).toBeDefined(); + }); +}); diff --git a/packages/webpack-bundler-runtime/src/index.ts b/packages/webpack-bundler-runtime/src/index.ts index d611983f45a..e719aea897c 100644 --- a/packages/webpack-bundler-runtime/src/index.ts +++ b/packages/webpack-bundler-runtime/src/index.ts @@ -8,8 +8,10 @@ import { attachShareScopeMap } from './attachShareScopeMap'; import { initContainerEntry } from './initContainerEntry'; import { init } from './init'; import { getSharedFallbackGetter } from './getSharedFallbackGetter'; +import { unloadRemotePlugin } from './unload-remote-plugin'; export * from './types'; +export { unloadRemotePlugin }; const federation: Federation = { runtime, diff --git a/packages/webpack-bundler-runtime/src/init.ts b/packages/webpack-bundler-runtime/src/init.ts index 688e86dbb77..1c3a34f07a3 100644 --- a/packages/webpack-bundler-runtime/src/init.ts +++ b/packages/webpack-bundler-runtime/src/init.ts @@ -5,6 +5,7 @@ import { } from '@module-federation/runtime'; import type { ShareArgs } from '@module-federation/runtime/types'; import helpers from '@module-federation/runtime/helpers'; +import { unloadRemotePlugin } from './unload-remote-plugin'; const WEBPACK_REQUIRE_SYMBOL = Symbol.for('mf_webpack_require'); @@ -127,7 +128,14 @@ export function init({ webpackRequire }: { webpackRequire: WebpackRequire }) { }; initOptions.plugins ||= []; - initOptions.plugins.push(treeShakingSharePlugin()); + const hasPlugin = (name: string) => + initOptions.plugins?.some((plugin) => plugin?.name === name); + if (!hasPlugin('tree-shake-plugin')) { + initOptions.plugins.push(treeShakingSharePlugin()); + } + if (!hasPlugin('unload-remote-plugin')) { + initOptions.plugins.push(unloadRemotePlugin()); + } const instance = runtime!.init(initOptions); (instance as unknown as Record)[WEBPACK_REQUIRE_SYMBOL] = webpackRequire; diff --git a/packages/webpack-bundler-runtime/src/unload-remote-plugin.ts b/packages/webpack-bundler-runtime/src/unload-remote-plugin.ts new file mode 100644 index 00000000000..297279ac6fb --- /dev/null +++ b/packages/webpack-bundler-runtime/src/unload-remote-plugin.ts @@ -0,0 +1,93 @@ +import type { + ModuleFederation, + ModuleFederationRuntimePlugin, +} from '@module-federation/runtime'; +import type { Remote } from '@module-federation/runtime/types'; +import { decodeName, ENCODE_NAME_PREFIX } from '@module-federation/sdk'; + +type WebpackRequire = { + c?: Record; + m?: Record; + federation?: { + bundlerRuntimeOptions?: { + remotes?: { + idToRemoteMap?: Record>; + idToExternalAndNameMapping?: Record; + }; + }; + }; +}; + +const WEBPACK_REQUIRE_SYMBOL = Symbol.for('mf_webpack_require'); + +const clearBundlerRemoteModuleCache = ( + origin: ModuleFederation, + remote: Remote, +) => { + const webpackRequire = ( + origin as ModuleFederation & { + [key: symbol]: unknown; + } + )[WEBPACK_REQUIRE_SYMBOL] as WebpackRequire | undefined; + const remotesOptions = + webpackRequire?.federation?.bundlerRuntimeOptions?.remotes; + if (!remotesOptions) { + return; + } + + const { idToRemoteMap = {}, idToExternalAndNameMapping = {} } = + remotesOptions; + const candidates = new Set( + [remote.name, remote.alias].filter(Boolean) as string[], + ); + if (!candidates.size) { + return; + } + + const normalizeName = (value: string): string => { + try { + return decodeName(value, ENCODE_NAME_PREFIX); + } catch { + return value; + } + }; + + Object.entries(idToRemoteMap).forEach(([moduleId, remoteInfos]) => { + if (!Array.isArray(remoteInfos)) { + return; + } + + const matched = remoteInfos.some((remoteInfo) => { + if (!remoteInfo?.name) { + return false; + } + const remoteName = remoteInfo.name; + return ( + candidates.has(remoteName) || candidates.has(normalizeName(remoteName)) + ); + }); + if (!matched) { + return; + } + + if (webpackRequire?.c) { + delete webpackRequire.c[moduleId]; + } + if (webpackRequire?.m) { + delete webpackRequire.m[moduleId]; + } + const mappingItem = idToExternalAndNameMapping[moduleId]; + if (mappingItem && typeof mappingItem === 'object' && 'p' in mappingItem) { + delete mappingItem.p; + } + }); +}; + +export function unloadRemotePlugin(): ModuleFederationRuntimePlugin { + return { + name: 'unload-remote-plugin', + afterRemoveRemote({ remote, origin }) { + clearBundlerRemoteModuleCache(origin, remote); + }, + }; +} From 01ad6adb0da9b443c44c765ae305c2d8928ee9dd Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 11 Feb 2026 19:00:01 -0800 Subject: [PATCH 09/13] chore(core): add changeset coverage for pr #4379 --- .changeset/auto-pr-4379-coverage.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/auto-pr-4379-coverage.md diff --git a/.changeset/auto-pr-4379-coverage.md b/.changeset/auto-pr-4379-coverage.md new file mode 100644 index 00000000000..dfbe5db538d --- /dev/null +++ b/.changeset/auto-pr-4379-coverage.md @@ -0,0 +1,10 @@ +--- +'@module-federation/enhanced': patch +'@module-federation/rsbuild-plugin': patch +'@module-federation/runtime': patch +'@module-federation/runtime-core': patch +'@module-federation/runtime-tools': patch +'@module-federation/webpack-bundler-runtime': patch +--- + +Add contextual changeset coverage for packages modified in PR #4379. From 789e174977f3eb1577ba3c31805b4d89997ad91c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 11 Feb 2026 19:13:37 -0800 Subject: [PATCH 10/13] chore(core): add contextual integration changeset for unload API --- .changeset/auto-pr-4379-coverage.md | 10 ---------- .changeset/lucky-fishes-smell.md | 7 +++++++ 2 files changed, 7 insertions(+), 10 deletions(-) delete mode 100644 .changeset/auto-pr-4379-coverage.md create mode 100644 .changeset/lucky-fishes-smell.md diff --git a/.changeset/auto-pr-4379-coverage.md b/.changeset/auto-pr-4379-coverage.md deleted file mode 100644 index dfbe5db538d..00000000000 --- a/.changeset/auto-pr-4379-coverage.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@module-federation/enhanced': patch -'@module-federation/rsbuild-plugin': patch -'@module-federation/runtime': patch -'@module-federation/runtime-core': patch -'@module-federation/runtime-tools': patch -'@module-federation/webpack-bundler-runtime': patch ---- - -Add contextual changeset coverage for packages modified in PR #4379. diff --git a/.changeset/lucky-fishes-smell.md b/.changeset/lucky-fishes-smell.md new file mode 100644 index 00000000000..e8d6d61a0f2 --- /dev/null +++ b/.changeset/lucky-fishes-smell.md @@ -0,0 +1,7 @@ +--- +'@module-federation/enhanced': patch +'@module-federation/rsbuild-plugin': patch +'@module-federation/runtime-tools': patch +--- + +Update enhanced and tooling integrations to consume the new optional runtime unload entrypoints. From 342c38dc03530d48541bd1e8618ab7e06d7ac430 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 24 Feb 2026 11:25:37 -0800 Subject: [PATCH 11/13] fix(runtime): guard unload cache purge by loaded state Pass loaded-state through afterRemoveRemote and skip webpack cache invalidation when the current host never loaded the remote. Add unloadRemote usage docs. Co-authored-by: Cursor --- .../docs/en/guide/runtime/runtime-api.mdx | 41 ++++++++++ .../docs/zh/guide/runtime/runtime-api.mdx | 41 ++++++++++ packages/runtime-core/src/remote/index.ts | 2 + packages/runtime/__tests__/unload.spec.ts | 1 + .../__tests__/unload-remote-plugin.spec.ts | 81 ++++++++++++++++--- .../src/unload-remote-plugin.ts | 5 +- 6 files changed, 157 insertions(+), 14 deletions(-) diff --git a/apps/website-new/docs/en/guide/runtime/runtime-api.mdx b/apps/website-new/docs/en/guide/runtime/runtime-api.mdx index 1d94de8cc9c..9ac85348842 100644 --- a/apps/website-new/docs/en/guide/runtime/runtime-api.mdx +++ b/apps/website-new/docs/en/guide/runtime/runtime-api.mdx @@ -671,6 +671,47 @@ loadShare('react', { +## unloadRemote + +- Type: `unloadRemote(nameOrAlias: string): boolean` +- Removes a registered remote and clears runtime state for that remote in the current host instance. +- Returns `true` when the remote exists and is removed, otherwise `false`. + +Use this API when you need deterministic teardown before switching remote entries, re-registering remotes, or cleaning up host state in long-lived sessions. + + + + ```tsx + import { unloadRemote } from '@module-federation/enhanced/runtime'; + + // Remove by remote name + unloadRemote('remote'); + + // Remove by alias + unloadRemote('app1'); + ``` + + + ```ts + import { createInstance } from '@module-federation/enhanced/runtime'; + + const mf = createInstance({ + name: 'mf_host', + remotes: [ + { + name: 'remote', + alias: 'app1', + entry: 'http://localhost:2001/mf-manifest.json', + }, + ], + }); + + mf.unloadRemote('remote'); + mf.unloadRemote('app1'); + ``` + + + ## preloadRemote diff --git a/apps/website-new/docs/zh/guide/runtime/runtime-api.mdx b/apps/website-new/docs/zh/guide/runtime/runtime-api.mdx index d9f71bd22d3..23da957e31a 100644 --- a/apps/website-new/docs/zh/guide/runtime/runtime-api.mdx +++ b/apps/website-new/docs/zh/guide/runtime/runtime-api.mdx @@ -672,6 +672,47 @@ loadShare('react', { +## unloadRemote + +- Type: `unloadRemote(nameOrAlias: string): boolean` +- 从当前 host 实例中移除已注册的 remote,并清理该 remote 对应的运行时状态。 +- 当 remote 存在并移除成功时返回 `true`,否则返回 `false`。 + +当你需要在切换 remote 地址、重新注册 remote,或在长生命周期应用中做确定性清理时,可以使用该 API。 + + + + ```tsx + import { unloadRemote } from '@module-federation/enhanced/runtime'; + + // 按 remote name 移除 + unloadRemote('remote'); + + // 按 alias 移除 + unloadRemote('app1'); + ``` + + + ```ts + import { createInstance } from '@module-federation/enhanced/runtime'; + + const mf = createInstance({ + name: 'mf_host', + remotes: [ + { + name: 'remote', + alias: 'app1', + entry: 'http://localhost:2001/mf-manifest.json', + }, + ], + }); + + mf.unloadRemote('remote'); + mf.unloadRemote('app1'); + ``` + + + ## preloadRemote diff --git a/packages/runtime-core/src/remote/index.ts b/packages/runtime-core/src/remote/index.ts index fe83cd1b762..5b9b08d2f73 100644 --- a/packages/runtime-core/src/remote/index.ts +++ b/packages/runtime-core/src/remote/index.ts @@ -77,6 +77,7 @@ export class RemoteHandler { { remote: Remote; origin: ModuleFederation; + loaded: boolean; }, ], void @@ -641,6 +642,7 @@ export class RemoteHandler { this.hooks.lifecycle.afterRemoveRemote.emit({ remote, origin: host, + loaded: Boolean(loadedModule), }); } catch (err) { logger.log('removeRemote fail: ', err); diff --git a/packages/runtime/__tests__/unload.spec.ts b/packages/runtime/__tests__/unload.spec.ts index fd17a0b5b60..09d61b81ce0 100644 --- a/packages/runtime/__tests__/unload.spec.ts +++ b/packages/runtime/__tests__/unload.spec.ts @@ -181,6 +181,7 @@ describe('unload api', () => { alias: 'app2', }), origin: FM, + loaded: false, }); }); diff --git a/packages/webpack-bundler-runtime/__tests__/unload-remote-plugin.spec.ts b/packages/webpack-bundler-runtime/__tests__/unload-remote-plugin.spec.ts index bcd56de07d9..5ed1792d2d5 100644 --- a/packages/webpack-bundler-runtime/__tests__/unload-remote-plugin.spec.ts +++ b/packages/webpack-bundler-runtime/__tests__/unload-remote-plugin.spec.ts @@ -2,21 +2,66 @@ import { ModuleFederation } from '@module-federation/runtime-core'; import { unloadRemotePlugin } from '../src/unload-remote-plugin'; describe('unloadRemotePlugin', () => { - test('clears webpack module cache and remote marker for unloaded remote only', () => { - const FM = new ModuleFederation({ - name: '@federation/runtime-unload-bundler-cache', - version: '1.0.1', - remotes: [ - { - name: '@register-remotes/app2', - alias: 'app2', - entry: - 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + test('skips webpack cache purge when remote was never loaded by host', () => { + const targetMapping: any = ['default', './say', 'external-target']; + targetMapping.p = Promise.resolve(1); + const untouchedMapping: any = ['default', './say', 'external-untouched']; + untouchedMapping.p = Promise.resolve(2); + + const webpackRequire: any = { + c: { + target: { id: 'target' }, + untouched: { id: 'untouched' }, + }, + m: { + target: () => null, + untouched: () => null, + }, + federation: { + bundlerRuntimeOptions: { + remotes: { + idToRemoteMap: { + target: [ + { externalType: 'script', name: '@register-remotes/app2' }, + ], + untouched: [{ externalType: 'script', name: 'other-remote' }], + }, + idToExternalAndNameMapping: { + target: targetMapping, + untouched: untouchedMapping, + }, + }, }, - ], + }, + }; + const origin: any = {}; + origin[Symbol.for('mf_webpack_require')] = webpackRequire; + + const plugin = unloadRemotePlugin(); + expect(plugin.afterRemoveRemote).toBeDefined(); + plugin.afterRemoveRemote?.({ + remote: { + name: '@register-remotes/app2', + alias: 'app2', + } as any, + origin, + loaded: false, }); - FM.registerPlugins([unloadRemotePlugin()]); + expect(webpackRequire.c.target).toBeDefined(); + expect(webpackRequire.m.target).toBeDefined(); + expect(targetMapping.p).toBeDefined(); + expect(webpackRequire.c.untouched).toBeDefined(); + expect(webpackRequire.m.untouched).toBeDefined(); + expect(untouchedMapping.p).toBeDefined(); + }); + + test('clears webpack module cache when loaded is true', () => { + const FM = new ModuleFederation({ + name: '@federation/runtime-unload-bundler-cache-loaded', + version: '1.0.1', + remotes: [], + }); const targetMapping: any = ['default', './say', 'external-target']; targetMapping.p = Promise.resolve(1); @@ -51,7 +96,17 @@ describe('unloadRemotePlugin', () => { }; (FM as any)[Symbol.for('mf_webpack_require')] = webpackRequire; - expect(FM.unloadRemote('@register-remotes/app2')).toBe(true); + const plugin = unloadRemotePlugin(); + expect(plugin.afterRemoveRemote).toBeDefined(); + plugin.afterRemoveRemote?.({ + remote: { + name: '@register-remotes/app2', + alias: 'app2', + } as any, + origin: FM, + loaded: true, + }); + expect(webpackRequire.c.target).toBeUndefined(); expect(webpackRequire.m.target).toBeUndefined(); expect(targetMapping.p).toBeUndefined(); diff --git a/packages/webpack-bundler-runtime/src/unload-remote-plugin.ts b/packages/webpack-bundler-runtime/src/unload-remote-plugin.ts index 297279ac6fb..1a8ccb22918 100644 --- a/packages/webpack-bundler-runtime/src/unload-remote-plugin.ts +++ b/packages/webpack-bundler-runtime/src/unload-remote-plugin.ts @@ -86,7 +86,10 @@ const clearBundlerRemoteModuleCache = ( export function unloadRemotePlugin(): ModuleFederationRuntimePlugin { return { name: 'unload-remote-plugin', - afterRemoveRemote({ remote, origin }) { + afterRemoveRemote({ remote, origin, loaded }) { + if (!loaded) { + return; + } clearBundlerRemoteModuleCache(origin, remote); }, }; From c286b4210aed38039e1960a00ed1e25b9edb60ac Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 24 Feb 2026 15:11:44 -0800 Subject: [PATCH 12/13] fix(dts-plugin): align workspace entrypoints and RawSource typing Resolve the dts-plugin TYPE-001 failure by correcting package entry paths for workspace dependencies and updating RawSource usage for webpack typings. Co-authored-by: Cursor --- .../dts-plugin/src/plugins/GenerateTypesPlugin.ts | 6 ++---- packages/error-codes/package.json | 8 ++++---- packages/sdk/package.json | 12 ++++++------ 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/dts-plugin/src/plugins/GenerateTypesPlugin.ts b/packages/dts-plugin/src/plugins/GenerateTypesPlugin.ts index a8b4aaeed5f..d103436ff60 100644 --- a/packages/dts-plugin/src/plugins/GenerateTypesPlugin.ts +++ b/packages/dts-plugin/src/plugins/GenerateTypesPlugin.ts @@ -172,8 +172,7 @@ export class GenerateTypesPlugin implements WebpackPluginInstance { compilation.emitAsset( zipName, new compiler.webpack.sources.RawSource( - fs.readFileSync(zipTypesPath), - false, + fs.readFileSync(zipTypesPath) as unknown as string, ), ); } @@ -186,8 +185,7 @@ export class GenerateTypesPlugin implements WebpackPluginInstance { compilation.emitAsset( apiFileName, new compiler.webpack.sources.RawSource( - fs.readFileSync(apiTypesPath), - false, + fs.readFileSync(apiTypesPath) as unknown as string, ), ); } diff --git a/packages/error-codes/package.json b/packages/error-codes/package.json index 126b794b07c..352010e14a9 100644 --- a/packages/error-codes/package.json +++ b/packages/error-codes/package.json @@ -25,14 +25,14 @@ "browser": { "url": false }, - "main": "./dist/index.cjs.js", - "module": "./dist/index.esm.mjs", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.esm.mjs", - "require": "./dist/index.cjs.js" + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" } }, "typesVersions": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index cc3f5bde8d4..85026bc4549 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -23,8 +23,8 @@ }, "author": "zhanghang ", "sideEffects": false, - "main": "./dist/index.cjs.cjs", - "module": "./dist/index.esm.js", + "main": "./dist/index.cjs", + "module": "./dist/index.js", "types": "./dist/index.d.ts", "browser": { "url": false @@ -33,21 +33,21 @@ ".": { "import": { "types": "./dist/index.d.ts", - "default": "./dist/index.esm.js" + "default": "./dist/index.js" }, "require": { "types": "./dist/index.d.ts", - "default": "./dist/index.cjs.cjs" + "default": "./dist/index.cjs" } }, "./normalize-webpack-path": { "import": { "types": "./dist/normalize-webpack-path.d.ts", - "default": "./dist/normalize-webpack-path.esm.js" + "default": "./dist/normalize-webpack-path.js" }, "require": { "types": "./dist/normalize-webpack-path.d.ts", - "default": "./dist/normalize-webpack-path.cjs.cjs" + "default": "./dist/normalize-webpack-path.cjs" } } }, From 226bc11b63bc38efed17b04725951a6da9fed2ee Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 24 Feb 2026 19:15:58 -0800 Subject: [PATCH 13/13] fix(sdk): align package entrypoints with emitted artifacts Restore sdk and error-codes export paths to the filenames emitted by the current build so CI package resolution no longer fails on these branches. Co-authored-by: Cursor --- packages/error-codes/package.json | 8 ++++---- packages/sdk/package.json | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/error-codes/package.json b/packages/error-codes/package.json index 352010e14a9..126b794b07c 100644 --- a/packages/error-codes/package.json +++ b/packages/error-codes/package.json @@ -25,14 +25,14 @@ "browser": { "url": false }, - "main": "./dist/index.cjs", - "module": "./dist/index.mjs", + "main": "./dist/index.cjs.js", + "module": "./dist/index.esm.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", - "require": "./dist/index.cjs" + "import": "./dist/index.esm.mjs", + "require": "./dist/index.cjs.js" } }, "typesVersions": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 85026bc4549..cc3f5bde8d4 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -23,8 +23,8 @@ }, "author": "zhanghang ", "sideEffects": false, - "main": "./dist/index.cjs", - "module": "./dist/index.js", + "main": "./dist/index.cjs.cjs", + "module": "./dist/index.esm.js", "types": "./dist/index.d.ts", "browser": { "url": false @@ -33,21 +33,21 @@ ".": { "import": { "types": "./dist/index.d.ts", - "default": "./dist/index.js" + "default": "./dist/index.esm.js" }, "require": { "types": "./dist/index.d.ts", - "default": "./dist/index.cjs" + "default": "./dist/index.cjs.cjs" } }, "./normalize-webpack-path": { "import": { "types": "./dist/normalize-webpack-path.d.ts", - "default": "./dist/normalize-webpack-path.js" + "default": "./dist/normalize-webpack-path.esm.js" }, "require": { "types": "./dist/normalize-webpack-path.d.ts", - "default": "./dist/normalize-webpack-path.cjs" + "default": "./dist/normalize-webpack-path.cjs.cjs" } } },