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.
diff --git a/.changeset/soft-carpets-develop.md b/.changeset/soft-carpets-develop.md
new file mode 100644
index 00000000000..5cec098a784
--- /dev/null
+++ b/.changeset/soft-carpets-develop.md
@@ -0,0 +1,15 @@
+---
+'@module-federation/runtime': minor
+'@module-federation/runtime-core': minor
+'@module-federation/webpack-bundler-runtime': minor
+---
+
+feat(runtime): move remote unload APIs to optional `@module-federation/runtime/unload` entry
+
+- 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/.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
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/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 5b6184fa2da..b7c9717820e 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';
diff --git a/packages/runtime-core/src/core.ts b/packages/runtime-core/src/core.ts
index 411dbefce01..7877ba8ef25 100644
--- a/packages/runtime-core/src/core.ts
+++ b/packages/runtime-core/src/core.ts
@@ -353,6 +353,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..5b9b08d2f73 100644
--- a/packages/runtime-core/src/remote/index.ts
+++ b/packages/runtime-core/src/remote/index.ts
@@ -72,6 +72,16 @@ export class RemoteHandler {
remote: Remote;
origin: ModuleFederation;
}>('registerRemote'),
+ afterRemoveRemote: new SyncHook<
+ [
+ {
+ remote: Remote;
+ origin: ModuleFederation;
+ loaded: boolean;
+ },
+ ],
+ void
+ >('afterRemoveRemote'),
beforeRequest: new AsyncWaterfallHook<{
id: string;
options: Options;
@@ -194,6 +204,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(
@@ -472,8 +493,16 @@ export class RemoteHandler {
host.options.remotes.splice(remoteIndex, 1);
}
const loadedModule = host.moduleCache.get(remote.name);
+ const remoteInfo = loadedModule
+ ? loadedModule.remoteInfo
+ : getRemoteInfo(remote);
+
+ if (remoteInfo.entry) {
+ host.snapshotHandler.manifestCache.delete(remoteInfo.entry);
+ }
+
+ // Only clean global/share state when this host actually loaded the remote.
if (loadedModule) {
- const remoteInfo = loadedModule.remoteInfo;
const key = remoteInfo.entryGlobalName as keyof typeof CurrentGlobal;
if (CurrentGlobal[key]) {
@@ -486,16 +515,12 @@ export class RemoteHandler {
CurrentGlobal[key] = undefined;
}
}
- const remoteEntryUniqueKey = getRemoteEntryUniqueKey(
- loadedModule.remoteInfo,
- );
+ const remoteEntryUniqueKey = getRemoteEntryUniqueKey(remoteInfo);
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)
@@ -574,27 +599,51 @@ 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);
+ 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);
}
+ });
- host.moduleCache.delete(remote.name);
+ 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.hooks.lifecycle.afterRemoveRemote.emit({
+ remote,
+ origin: host,
+ loaded: Boolean(loadedModule),
+ });
} 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 bf870059e25..b0edfc3de56 100644
--- a/packages/runtime-tools/src/webpack-bundler-runtime.ts
+++ b/packages/runtime-tools/src/webpack-bundler-runtime.ts
@@ -1,8 +1,2 @@
-import webpackBundlerRuntime from '@module-federation/webpack-bundler-runtime';
-
-const normalizedWebpackBundlerRuntime =
- // Support both CJS module.exports payload and transpiled default payload.
- (webpackBundlerRuntime as { default?: unknown }).default ??
- webpackBundlerRuntime;
-
-export default normalizedWebpackBundlerRuntime;
+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
new file mode 100644
index 00000000000..09d61b81ce0
--- /dev/null
+++ b/packages/runtime/__tests__/unload.spec.ts
@@ -0,0 +1,209 @@
+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('emits afterRemoveRemote hook on unload', () => {
+ const afterRemoveRemote = vi.fn();
+ 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([
+ {
+ name: 'after-remove-remote-test',
+ afterRemoveRemote,
+ },
+ ]);
+
+ expect(unloadRemoteFromInstance(FM, '@register-remotes/app2')).toBe(true);
+ expect(afterRemoveRemote).toHaveBeenCalledTimes(1);
+ expect(afterRemoveRemote).toHaveBeenCalledWith({
+ remote: expect.objectContaining({
+ name: '@register-remotes/app2',
+ alias: 'app2',
+ }),
+ origin: FM,
+ loaded: false,
+ });
+ });
+
+ 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 3c4f1f33236..9c668e75502 100644
--- a/packages/runtime/package.json
+++ b/packages/runtime/package.json
@@ -63,6 +63,16 @@
"default": "./dist/core.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": {
@@ -78,6 +88,9 @@
],
"core": [
"./dist/core.d.ts"
+ ],
+ "unload": [
+ "./dist/unload.d.ts"
]
}
},
diff --git a/packages/runtime/project.json b/packages/runtime/project.json
index 9fe80724f83..d665d590e99 100644
--- a/packages/runtime/project.json
+++ b/packages/runtime/project.json
@@ -6,17 +6,26 @@
"tags": ["type:pkg"],
"targets": {
"build": {
- "executor": "nx:run-commands",
+ "executor": "@nx/rollup:rollup",
"outputs": ["{workspaceRoot}/packages/runtime/dist"],
"options": {
"parallel": false,
- "cwd": "packages/runtime",
- "commands": [
- {
- "command": "tsdown --config tsdown.config.ts",
- "forwardAllArgs": false
- }
- ]
+ "outputPath": "packages/runtime/dist",
+ "main": "packages/runtime/src/index.ts",
+ "additionalEntryPoints": [
+ "packages/runtime/src/types.ts",
+ "packages/runtime/src/helpers.ts",
+ "packages/runtime/src/unload.ts"
+ ],
+ "tsConfig": "packages/runtime/tsconfig.lib.json",
+ "assets": [],
+ "external": ["@module-federation/*"],
+ "project": "packages/runtime/package.json",
+ "compiler": "tsc",
+ "rollupConfig": "packages/runtime/rollup.config.cjs",
+ "format": ["cjs", "esm"],
+ "generatePackageJson": false,
+ "useLegacyTypescriptPlugin": false
},
"dependsOn": [
{
@@ -39,10 +48,9 @@
"executor": "nx:run-commands",
"options": {
"parallel": false,
- "cwd": "packages/runtime",
"commands": [
{
- "command": "RUNTIME_TSDOWN_MODE=debug tsdown --config tsdown.config.ts",
+ "command": "FEDERATION_DEBUG=true nx run runtime:build",
"forwardAllArgs": false
}
]
diff --git a/packages/runtime/rollup.config.cjs b/packages/runtime/rollup.config.cjs
new file mode 100644
index 00000000000..75be5dad74e
--- /dev/null
+++ b/packages/runtime/rollup.config.cjs
@@ -0,0 +1,95 @@
+const replace = require('@rollup/plugin-replace');
+const copy = require('rollup-plugin-copy');
+
+const FEDERATION_DEBUG = process.env.FEDERATION_DEBUG || '';
+
+const adjustSourceMapPath = (relativePath) => {
+ const normalized = relativePath.replace(/\\/g, '/');
+ if (normalized.startsWith('../../src/')) {
+ return normalized.replace('../../src/', '../src/');
+ }
+ return normalized;
+};
+
+module.exports = (rollupConfig, projectOptions) => {
+ rollupConfig.treeshake = { preset: 'recommended' };
+
+ rollupConfig.input = {
+ index: 'packages/runtime/src/index.ts',
+ 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');
+
+ if (rollupConfig.output.format === 'esm' && FEDERATION_DEBUG) {
+ rollupConfig.output.format = 'iife';
+ rollupConfig.output.inlineDynamicImports = true;
+ delete rollupConfig.external;
+ delete rollupConfig.input.type;
+ delete rollupConfig.input.helpers;
+ }
+
+ if (Array.isArray(rollupConfig.output)) {
+ rollupConfig.output = rollupConfig.output.map((c) => ({
+ ...c,
+ sourcemap: true,
+ manualChunks: (id) => {
+ if (id.includes('@swc/helpers')) {
+ return 'polyfills';
+ }
+ },
+ hoistTransitiveImports: false,
+ sourcemapPathTransform: adjustSourceMapPath,
+ entryFileNames:
+ c.format === 'cjs'
+ ? c.entryFileNames.replace(/\.js$/, '.cjs')
+ : c.entryFileNames,
+ chunkFileNames:
+ c.format === 'cjs'
+ ? c.chunkFileNames.replace(/\.js$/, '.cjs')
+ : c.chunkFileNames,
+ ...(c.format === 'cjs' ? { externalLiveBindings: false } : {}),
+ }));
+ } else {
+ rollupConfig.output = {
+ ...rollupConfig.output,
+ sourcemap: true,
+ manualChunks: (id) => {
+ if (id.includes('@swc/helpers')) {
+ return 'polyfills';
+ }
+ },
+ hoistTransitiveImports: false,
+ sourcemapPathTransform: adjustSourceMapPath,
+ entryFileNames:
+ rollupConfig.output.format === 'cjs'
+ ? rollupConfig.output.entryFileNames.replace(/\.js$/, '.cjs')
+ : rollupConfig.output.entryFileNames,
+ chunkFileNames:
+ rollupConfig.output.format === 'cjs'
+ ? rollupConfig.output.chunkFileNames.replace(/\.js$/, '.cjs')
+ : rollupConfig.output.chunkFileNames,
+ ...(rollupConfig.output.format === 'cjs'
+ ? { externalLiveBindings: false }
+ : {}),
+ };
+ }
+
+ rollupConfig.plugins.push(
+ replace({
+ preventAssignment: true,
+ __VERSION__: JSON.stringify(pkg.version),
+ FEDERATION_DEBUG: JSON.stringify(FEDERATION_DEBUG),
+ }),
+ copy({
+ targets: [
+ { src: 'packages/runtime/LICENSE', dest: 'packages/runtime/dist' },
+ ],
+ }),
+ );
+
+ return rollupConfig;
+};
diff --git a/packages/runtime/src/unload.ts b/packages/runtime/src/unload.ts
new file mode 100644
index 00000000000..e7de8817951
--- /dev/null
+++ b/packages/runtime/src/unload.ts
@@ -0,0 +1,20 @@
+import {
+ getShortErrorMsg,
+ RUNTIME_009,
+ runtimeDescMap,
+} from '@module-federation/error-codes';
+import { assert, type ModuleFederation } from '@module-federation/runtime-core';
+import { getInstance } from './index';
+
+export function unloadRemoteFromInstance(
+ instance: ModuleFederation,
+ nameOrAlias: string,
+): boolean {
+ return instance.unloadRemote(nameOrAlias);
+}
+
+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/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
new file mode 100644
index 00000000000..c32f78a4e67
--- /dev/null
+++ b/packages/webpack-bundler-runtime/__tests__/init.spec.ts
@@ -0,0 +1,121 @@
+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 runtime plugins', () => {
+ 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(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..5ed1792d2d5
--- /dev/null
+++ b/packages/webpack-bundler-runtime/__tests__/unload-remote-plugin.spec.ts
@@ -0,0 +1,117 @@
+import { ModuleFederation } from '@module-federation/runtime-core';
+import { unloadRemotePlugin } from '../src/unload-remote-plugin';
+
+describe('unloadRemotePlugin', () => {
+ 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,
+ });
+
+ 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);
+ 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 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();
+ 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 dc9b130f0eb..7157496089e 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 fc2e8260a5a..1c3a34f07a3 100644
--- a/packages/webpack-bundler-runtime/src/init.ts
+++ b/packages/webpack-bundler-runtime/src/init.ts
@@ -3,8 +3,11 @@ import {
getRemoteEntry,
type ModuleFederationRuntimePlugin,
} from '@module-federation/runtime';
-import { ShareArgs } from '@module-federation/runtime/types';
+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');
export function init({ webpackRequire }: { webpackRequire: WebpackRequire }) {
const { initOptions, runtime, sharedFallback, bundlerRuntime, libraryType } =
@@ -125,6 +128,16 @@ export function init({ webpackRequire }: { webpackRequire: WebpackRequire }) {
};
initOptions.plugins ||= [];
- initOptions.plugins.push(treeShakingSharePlugin());
- return runtime!.init(initOptions);
+ 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;
+ return instance;
}
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..1a8ccb22918
--- /dev/null
+++ b/packages/webpack-bundler-runtime/src/unload-remote-plugin.ts
@@ -0,0 +1,96 @@
+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, loaded }) {
+ if (!loaded) {
+ return;
+ }
+ clearBundlerRemoteModuleCache(origin, remote);
+ },
+ };
+}