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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/ssr-provider-registration-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@module-federation/runtime-core": patch
"@module-federation/webpack-bundler-runtime": patch
---

Preserve shared provider factories when removing a remote registered under a different name from its container. Resolve provider ownership using the container global name as well as the registration name, including already-detached provider runtimes.
14 changes: 14 additions & 0 deletions apps/modernjs-ssr/another_remote/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import mfConfig from './module-federation.config';

export default defineConfig({
source: {
// This fixture checks production shared-provider GC. React development
// elements retain initialization CallSites in _debugStack, which can keep
// the importing exposed module alive independently of federation caches.
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
},
entry: {
index: './src/sharedConsumer.ts',
},
Expand All @@ -22,5 +28,13 @@ export default defineConfig({
server: {
port: 3057,
},
tools: {
rspack: {
optimization: {
// Use the explicit production define above even with `rslib mf-dev`.
nodeEnv: false,
},
},
},
plugins: [pluginModuleFederation(mfConfig, { target: 'dual' })],
});
5 changes: 5 additions & 0 deletions apps/modernjs-ssr/host/modern.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ const typeCheckerTypeScriptPath = require.resolve('typescript-compiler');

// https://modernjs.dev/en/configure/app/usage
export default defineConfig({
// Keep the cross-request cache probes in one SSR module generation. A first
// browser visit must not trigger lazy compilation and reset their state.
dev: {
lazyCompilation: false,
},
server: {
ssr: {
mode: 'stream',
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@
},
"overrides": {
"@changesets/assemble-release-plan": "workspace:*",
"@rspack/core": "npm:@rspack-canary/core@2.2.3-canary-8e63776c-20260908113208",
"@rspack/cli": "npm:@rspack-canary/cli@2.2.3-canary-8e63776c-20260908113208",
"@rspack/core": "npm:@rspack-canary/core@2.2.3-canary-76e8f696-20260911033013",
"@rspack/cli": "npm:@rspack-canary/cli@2.2.3-canary-76e8f696-20260911033013",
"ajv": "8.18.0",
"eslint>ajv": "6.14.0",
"@eslint/eslintrc>ajv": "6.14.0",
Expand Down Expand Up @@ -211,8 +211,8 @@
"@rollup/plugin-alias": "5.1.1",
"@rollup/plugin-replace": "6.0.1",
"@rslib/core": "^0.23.2",
"@rspack/cli": "npm:@rspack-canary/cli@2.0.9-canary-cb6bd31a-20260617080342",
"@rspack/core": "npm:@rspack-canary/core@2.0.9-canary-cb6bd31a-20260617080342",
"@rspack/cli": "npm:@rspack-canary/cli@2.2.3-canary-76e8f696-20260911033013",
"@rspack/core": "npm:@rspack-canary/core@2.2.3-canary-76e8f696-20260911033013",
"@rspack/dev-server": "2.1.0",
"@rstest/core": "^0.10.6",
"@storybook/addon-docs": "9.0.17",
Expand Down
184 changes: 96 additions & 88 deletions packages/runtime-core/__tests__/register-remotes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,104 +380,112 @@ describe('ModuleFederation', () => {
expect(FM.snapshotHandler.manifestCache.has(entry)).toBe(false);
});

it.each([
{ registered: true, loading: false },
{ registered: false, loading: false },
{ registered: true, loading: true },
{ registered: false, loading: true },
])('keeps shared provider caches: %j', async ({ registered, loading }) => {
const entry =
'http://localhost:1111/resources/register-remotes/app1/federation-remote-entry.js';
const remoteEntryClear = rs.fn();
const selectiveClear = rs.fn();
const shared: any = {
version: '1.0.0',
from: '@register-remotes/app1',
get: rs.fn(),
shareConfig: { requiredVersion: false },
scope: ['default'],
useIn: loading
? ['@register-remotes/app1']
: ['@register-remotes/app1', 'another-remote'],
deps: [],
lib: loading ? undefined : () => ({ value: 'shared from app1' }),
loaded: !loading,
loading: loading
? Promise.resolve(() => ({ value: 'shared from app1' }))
: undefined,
strategy: 'version-first' as const,
};
const FM = new ModuleFederation({
name: '@federation/instance',
version: '1.0.1',
remotes: [
{
it.each(
[
{ registered: true, loading: false },
{ registered: false, loading: false },
{ registered: true, loading: true },
{ registered: false, loading: true },
].flatMap((flags) =>
['@register-remotes/app1', 'app1'].map((providerName) => ({
...flags,
providerName,
})),
),
)(
'keeps shared provider caches: %j',
async ({ registered, loading, providerName }) => {
const entry =
'http://localhost:1111/resources/register-remotes/app1/federation-remote-entry.js';
const remoteEntryClear = rs.fn();
const selectiveClear = rs.fn();
const shared: any = {
version: '1.0.0',
from: providerName,
get: rs.fn(),
shareConfig: { requiredVersion: false },
scope: ['default'],
useIn: loading ? [providerName] : [providerName, 'another-remote'],
deps: [],
lib: loading ? undefined : () => ({ value: 'shared from app1' }),
loaded: !loading,
loading: loading
? Promise.resolve(() => ({ value: 'shared from app1' }))
: undefined,
strategy: 'version-first' as const,
};
const FM = new ModuleFederation({
name: '@federation/instance',
version: '1.0.1',
remotes: [
{
name: '@register-remotes/app1',
alias: 'app1',
entry,
},
],
});
const previousInstances = [...Global.__FEDERATION__.__INSTANCES__];
const previousShareScope = Global.__FEDERATION__.__SHARE__;

FM.moduleCache.set('@register-remotes/app1', {
remoteInfo: {
name: '@register-remotes/app1',
alias: 'app1',
entry,
type: 'global',
entryGlobalName: 'app1',
shareScope: 'default',
},
],
});
const previousInstances = [...Global.__FEDERATION__.__INSTANCES__];
const previousShareScope = Global.__FEDERATION__.__SHARE__;

FM.moduleCache.set('@register-remotes/app1', {
remoteInfo: {
name: '@register-remotes/app1',
alias: 'app1',
entry,
type: 'global',
entryGlobalName: 'app1',
shareScope: 'default',
},
remoteEntryExports: {
get: rs.fn(),
init: rs.fn(),
remoteEntryExports: {
get: rs.fn(),
init: rs.fn(),
__webpack_clear_cache__: remoteEntryClear,
__webpack_clear_exposed_cache__: selectiveClear,
},
} as any);
(globalThis as any).app1 = {
__webpack_clear_cache__: remoteEntryClear,
__webpack_clear_exposed_cache__: selectiveClear,
},
} as any);
(globalThis as any).app1 = {
__webpack_clear_cache__: remoteEntryClear,
__webpack_clear_exposed_cache__: selectiveClear,
};
if (registered)
Global.__FEDERATION__.__INSTANCES__.push({
name: '@register-remotes/app1',
options: { id: '@register-remotes/app1' },
shareScopeMap: {},
} as any);
Global.__FEDERATION__.__SHARE__ = {
'@register-remotes/app1': {
default: {
'shared-from-app1': {
'1.0.0': shared,
};
if (registered)
Global.__FEDERATION__.__INSTANCES__.push({
name: providerName,
options: { id: providerName },
shareScopeMap: {},
} as any);
Global.__FEDERATION__.__SHARE__ = {
[providerName]: {
default: {
'shared-from-app1': {
'1.0.0': shared,
},
},
},
},
};
};

try {
await FM.removeRemote('app1');
try {
await FM.removeRemote('app1');

expect(FM.options.remotes).toHaveLength(0);
expect(remoteEntryClear).not.toHaveBeenCalled();
expect(selectiveClear).toHaveBeenCalledTimes(2);
expect(FM.moduleCache.has('@register-remotes/app1')).toBe(false);
expect((globalThis as any).app1).toBeDefined();
expect(shared.from).toBe('@register-remotes/app1');
expect(shared.providerState).toBe(1);
expect(shared.useIn).toEqual(loading ? [] : ['another-remote']);
} finally {
Global.__FEDERATION__.__INSTANCES__.splice(
0,
Global.__FEDERATION__.__INSTANCES__.length,
...previousInstances,
);
Global.__FEDERATION__.__SHARE__ = previousShareScope;
delete (globalThis as any).app1;
}
});
expect(FM.options.remotes).toHaveLength(0);
expect(remoteEntryClear).not.toHaveBeenCalled();
expect(selectiveClear).toHaveBeenCalledTimes(2);
expect(FM.moduleCache.has('@register-remotes/app1')).toBe(false);
expect((globalThis as any).app1).toBeDefined();
expect(shared.from).toBe(providerName);
expect(shared.providerState).toBe(1);
expect(shared.useIn).toEqual(loading ? [] : ['another-remote']);
} finally {
Global.__FEDERATION__.__INSTANCES__.splice(
0,
Global.__FEDERATION__.__INSTANCES__.length,
...previousInstances,
);
Global.__FEDERATION__.__SHARE__ = previousShareScope;
delete (globalThis as any).app1;
}
},
);

it('keeps loaded remote cleanup context when removeRemote hook clears moduleCache first', async () => {
const entry =
Expand Down
25 changes: 19 additions & 6 deletions packages/runtime-core/src/remote/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -785,15 +785,28 @@ export class RemoteHandler {

// Keep a removed provider's runtime alive while another remote uses it.
let preserveRemoteRuntime = false;
// A runtime registration name may differ from the container/provider
// name (for example dynamic -> catalog). Shared.from uses the provider
// identity, so scanning only the registration name can clear live libs.
const providerNames = new Set(
[remoteInfo.name, remoteInfo.entryGlobalName].filter(Boolean),
);
Comment on lines +791 to +793

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the manifest's provider name

When a manifest remote is registered as dynamic, declares its federation name as v1, and uses a distinct metaData.globalName such as __FEDERATION_v1:custom__, this set contains only dynamic and the encoded global name. assignRemoteInfo() does not copy the manifest name, while SharedHandler.registerShared() sets Shared.from to the provider's federation name (v1), so neither cleanup layer recognizes the live share and the provider execution cache is fully cleared, recreating the singleton this change intends to retain. Carry the manifest/container name into the ownership metadata rather than assuming it equals the entry global.

Useful? React with 👍 / 👎.

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;
return Array.from(providerNames).some(
(providerName) =>
ins.options.id ===
composeKeyWithSeparator(
providerName,
remoteInfo.buildVersion,
),
);
} else {
return ins.name === remoteInsId;
return providerNames.has(ins.name);
}
});
const remoteIns =
Expand All @@ -802,6 +815,7 @@ export class RemoteHandler {
: undefined;
if (remoteIns) {
remoteInsId = remoteIns.options.id || remoteInsId;
providerNames.add(remoteIns.name);
}
// Shared factories can outlive removal from the instance registry.
// Scan their ownership even when this provider was removed before.
Expand All @@ -823,19 +837,18 @@ export class RemoteHandler {
if (
shared &&
typeof shared === 'object' &&
shared.from === remoteInfo.name
providerNames.has(shared.from)
) {
const hasExternalConsumer = shared.useIn.some(
(usedHostName) => usedHostName !== remoteInfo.name,
(usedHostName) => usedHostName !== shared.from,
);
if (
shared.loaded ||
shared.loading ||
hasExternalConsumer
) {
shared.useIn = shared.useIn.filter(
(usedHostName) =>
usedHostName !== remoteInfo.name,
(usedHostName) => usedHostName !== shared.from,
);
if (shared.useIn.length || shared.loading) {
isAllSharedNotUsed = false;
Expand Down
Loading
Loading