Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
41fcea8
fix(runtime): type disabled handlers and select remote entry
ScriptedAlchemy Sep 21, 2026
8a0b6b0
fix(runtime): expose handler members bridge-react already calls
ScriptedAlchemy Sep 21, 2026
0bafc46
test(sdk): give the platform graph check more time
ScriptedAlchemy Sep 21, 2026
23069e3
test(runtime): assert selector results instead of file names
ScriptedAlchemy Sep 21, 2026
6e3d166
test(runtime): stop minifying selector bundles
ScriptedAlchemy Sep 21, 2026
fec2080
fix(runtime-core): keep public getRemoteEntry and handler types uncha…
ScriptedAlchemy Sep 24, 2026
9531a7f
fix(webpack-bundler-runtime): patch tree-shaking status without a bun…
ScriptedAlchemy Sep 24, 2026
ed22786
test(runtime): prove disabled code stays out of selector bundles
ScriptedAlchemy Sep 24, 2026
53096c3
Merge branch 'rfc5036/06-docs-rollout' into rfc5036/07-handler-contracts
ScriptedAlchemy Sep 24, 2026
c6e9d47
Merge branch 'rfc5036/06-docs-rollout' into rfc5036/07-handler-contracts
ScriptedAlchemy Sep 24, 2026
9fee7a1
Merge branch 'rfc5036/06-docs-rollout' into rfc5036/07-handler-contracts
ScriptedAlchemy Sep 24, 2026
572f237
docs(changeset): drop handler notes that moved to the selector PR
ScriptedAlchemy Sep 24, 2026
ddf5210
Merge branch 'rfc5036/06-docs-rollout' into rfc5036/07-handler-contracts
ScriptedAlchemy Sep 24, 2026
00fef64
Merge branch 'rfc5036/06-docs-rollout' into rfc5036/07-handler-contracts
ScriptedAlchemy Sep 24, 2026
caf8b82
Merge branch 'rfc5036/06-docs-rollout' into rfc5036/07-handler-contracts
ScriptedAlchemy Sep 25, 2026
8de661b
Merge branch 'rfc5036/06-docs-rollout' into rfc5036/07-handler-contracts
ScriptedAlchemy Sep 25, 2026
eb7de9b
Merge branch 'rfc5036/06-docs-rollout' into rfc5036/07-handler-contracts
ScriptedAlchemy Sep 25, 2026
077ee0a
Merge branch 'rfc5036/06-docs-rollout' into rfc5036/07-handler-contracts
ScriptedAlchemy Sep 25, 2026
c813c4f
Merge branch 'rfc5036/06-docs-rollout' into rfc5036/07-handler-contracts
ScriptedAlchemy Sep 25, 2026
32e6e1d
Merge branch 'update/rfc5036-06' into update/rfc5036-07
ScriptedAlchemy Oct 3, 2026
509e69a
Merge updated RFC5036 docs parent
ScriptedAlchemy Oct 3, 2026
6ae5e8d
Merge validated RFC5036 payload fix parent
ScriptedAlchemy Oct 3, 2026
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
9 changes: 9 additions & 0 deletions .changeset/runtime-handler-contracts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@module-federation/runtime-core': patch
'@module-federation/webpack-bundler-runtime': patch
---

The tree-shaking share plugin moves behind a package selector and still
patches tree-shaking status when no bundler runtime is present. The public
`getRemoteEntry` behavior is unchanged. Selector tests check the result of
each call and that disabled code is absent from webpack and Rspack bundles.
151 changes: 85 additions & 66 deletions packages/runtime-core/__tests__/remote-module-selector.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,10 @@ import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { TextDecoder, TextEncoder } from 'node:util';
import type webpack from 'webpack';
import { runNodeWithConditions } from '../../../tools/testing/runNodeWithConditions';

const packageDir = path.resolve(__dirname, '..');
type CompilerFactory = typeof webpack;
type StatsModule = {
name?: string;
identifier?: string;
modules?: StatsModule[];
};

function runNode(conditions: string[], code: string): string {
const output = execFileSync(
process.execPath,
[...conditions.map((condition) => `--conditions=${condition}`), '-e', code],
{ cwd: packageDir, encoding: 'utf8' },
);
return output.trim();
}

function compilerCases(): [string, CompilerFactory][] {
Object.defineProperties(globalThis, {
Expand All @@ -45,25 +32,27 @@ function compilerCases(): [string, CompilerFactory][] {
];
}

function flattenModules(modules: StatsModule[]): StatsModule[] {
return modules.flatMap((module) => [
module,
...flattenModules(module.modules ?? []),
]);
interface CompiledRun {
output: string;
bundle: string;
}

function selectedModules(
const REMOTE_DISABLED_MESSAGE =
'Remote loading is disabled by experiments.optimization.disableRemote.';
const REMOTE_MODULE_MARKER = 'mf_module_id';

function runCompiled(
compilerFactory: CompilerFactory,
name: string,
): Promise<string[]> {
condition: string,
entrySource: string,
): Promise<CompiledRun> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mf-selector-'));
fs.writeFileSync(
path.join(root, 'entry.js'),
'import { Module } from "@module-federation/runtime-core";\nexport default Module;\n',
);
fs.writeFileSync(path.join(root, 'entry.js'), entrySource);
return new Promise((resolve, reject) => {
const compiler = compilerFactory({
context: packageDir,
target: 'node',
mode: 'none',
entry: path.join(root, 'entry.js'),
output: { path: path.join(root, name), filename: 'out.js' },
Expand All @@ -74,51 +63,59 @@ function selectedModules(
'dist/index.js',
),
},
conditionNames: ['module-federation:no-remote', 'import', '...'],
conditionNames: [condition, 'import', '...'],
},
optimization: {
minimize: false,
},
externals: [
(
{ request }: { request?: string },
callback: (error?: Error | null, result?: string) => void,
) => {
if (
request?.startsWith('@module-federation/') &&
request !== '@module-federation/runtime-core'
) {
callback(null, `commonjs ${request}`);
return;
}
callback();
},
],
});
compiler.run((error, stats) => {
compiler.close(() => undefined);
fs.rmSync(root, { recursive: true, force: true });
const finish = (result: CompiledRun | Error) => {
compiler.close(() => undefined);
fs.rmSync(root, { recursive: true, force: true });
if (result instanceof Error) {
reject(result);
return;
}
resolve(result);
};
if (error) {
reject(error);
finish(error);
return;
}
const info = stats?.toJson({ modules: true });
if (stats?.hasErrors()) {
reject(new Error(info?.errors?.[0]?.message ?? 'compile failed'));
finish(
new Error(stats.toJson().errors?.[0]?.message ?? 'compile failed'),
);
return;
}
const modules = flattenModules(
(info?.modules ?? []) as unknown as StatsModule[],
);
resolve(modules.map((module) => module.name ?? module.identifier ?? ''));
try {
const bundlePath = path.join(root, name, 'out.js');
const output = execFileSync(process.execPath, [bundlePath], {
encoding: 'utf8',
env: {
...process.env,
NODE_PATH: path.resolve(packageDir, '../../node_modules'),
},
});
finish({
output: output.trim(),
bundle: fs.readFileSync(bundlePath, 'utf8'),
});
} catch (runError) {
finish(
runError instanceof Error ? runError : new Error(String(runError)),
);
}
});
});
}

describe('remote module selector packaging', () => {
it('keeps the remote module available by default', () => {
expect(
runNode(
runNodeWithConditions(
packageDir,
[],
"const { Module } = require('#mf/remote-module'); console.log(new Module({ remoteInfo: { name: 'remote' }, host: {} }).remoteInfo.name)",
),
Expand All @@ -127,27 +124,47 @@ describe('remote module selector packaging', () => {

it('exports the unavailable module for the no-remote condition', () => {
expect(
runNode(
runNodeWithConditions(
packageDir,
['module-federation:no-remote'],
"const { Module } = require('#mf/remote-module'); try { new Module() } catch (error) { console.log(error.message) }",
),
).toBe(
'Remote loading is disabled by experiments.optimization.disableRemote.',
);
).toBe(REMOTE_DISABLED_MESSAGE);
});

it('lets webpack and rspack select the disabled leaf by condition name', async () => {
const selected = await Promise.all(
it('lets webpack and rspack load the disabled remote module and drop the remote module class', async () => {
const disabled = await Promise.all(
compilerCases().map(([name, compiler]) =>
selectedModules(compiler, name),
runCompiled(
compiler,
`${name}-off`,
'module-federation:no-remote',
"import { Module } from '@module-federation/runtime-core'; try { new Module(); } catch (error) { console.log(error.message); }",
),
),
);
for (const modules of selected) {
expect(modules).toContain('./dist/selectors/remote-module/disabled.js');
expect(modules).toContain('./dist/remote/disabled.js');
expect(modules).not.toContain('./dist/selectors/remote-module/legacy.js');
expect(modules).not.toContain('./dist/module/index.js');
}
expect(disabled.map((run) => run.output)).toEqual([
REMOTE_DISABLED_MESSAGE,
REMOTE_DISABLED_MESSAGE,
]);
disabled.forEach((run) => {
expect(run.bundle).not.toContain(REMOTE_MODULE_MARKER);
});

const enabled = await Promise.all(
compilerCases().map(([name, compiler]) =>
runCompiled(
compiler,
name,
'import',
"import { Module } from '@module-federation/runtime-core'; console.log(new Module({ remoteInfo: { name: 'remote' }, host: {} }).remoteInfo.name);",
),
),
);
expect(enabled.map((run) => run.output)).toEqual(['remote', 'remote']);
enabled.forEach((run) => {
expect(run.bundle).toContain(REMOTE_MODULE_MARKER);
});
}, 60_000);

it('loads the enabled leaf in both module formats', () => {
Expand All @@ -159,13 +176,15 @@ describe('remote module selector packaging', () => {
path.join(packageDir, 'dist/selectors/remote-module/enabled.js'),
).href;
expect(
runNode(
runNodeWithConditions(
packageDir,
[],
`const { Module } = require(${JSON.stringify(cjsPath)}); console.log(new Module({ remoteInfo: { name: 'remote' }, host: {} }).remoteInfo.name)`,
),
).toBe('remote');
expect(
runNode(
runNodeWithConditions(
packageDir,
[],
`import(${JSON.stringify(esmUrl)}).then(({ Module }) => console.log(new Module({ remoteInfo: { name: 'remote' }, host: {} }).remoteInfo.name))`,
),
Expand Down
4 changes: 2 additions & 2 deletions packages/runtime-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ export {
type RuntimeImageDescriptorV1,
} from './runtimeImage';
export {
getRemoteEntry,
getRemoteInfo,
isStaticResourcesEqual,
matchRemoteWithNameAndExpose,
safeWrapper,
} from './utils';
export { getRegisteredShare } from '../src/utils/share';
export { getRemoteEntry } from './utils/load';
export { getRegisteredShare } from '#mf/share-utils';
export { loadScript, loadScriptNode } from '@module-federation/sdk';
export { Module } from '#mf/remote-module';
export * as types from './type';
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime-core/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ export * from './tool';
export * from './manifest';
export * from './logger';
export * from './plugin';
export * from './load';
export * from './remoteInfo';
export * from './context';
35 changes: 8 additions & 27 deletions packages/runtime-core/src/utils/load.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import {
loadScript,
loadScriptNode,
composeKeyWithSeparator,
isBrowserEnvValue,
} from '@module-federation/sdk';
import { DEFAULT_REMOTE_TYPE, DEFAULT_SCOPE } from '../constant';
import { ModuleFederation } from '../core';
import {
globalLoading,
Expand All @@ -13,18 +11,20 @@ import {
type RemoteEntryCacheDescriptorV1,
} from '../global';
import { readRuntimeImage } from '../runtimeImage';
import {
Remote,
RemoteEntryExports,
RemoteInfo,
ResourceLoadContext,
} from '../type';
import { RemoteEntryExports, RemoteInfo, ResourceLoadContext } from '../type';
import { assert, error } from './logger';
import {
RUNTIME_001,
RUNTIME_008,
runtimeDescMap,
} from '@module-federation/error-codes';
import { getRemoteEntryUniqueKey, isEsmRemoteType } from './remoteInfo';

export {
getRemoteEntryUniqueKey,
getRemoteInfo,
isEsmRemoteType,
} from './remoteInfo';

const importCallback = '.then(callbacks[0]).catch(callbacks[1])';
const remoteEntryLoadingOrigins = new WeakMap<
Expand All @@ -48,10 +48,6 @@ function isEsmRemoteEntryLoadError(err: unknown): boolean {
);
}

export function isEsmRemoteType(type: RemoteInfo['type']): boolean {
return type === 'esm' || type === 'module';
}

async function loadEsmEntry({
entry,
remoteEntryExports,
Expand Down Expand Up @@ -362,11 +358,6 @@ async function loadEntryNode({
});
}

export function getRemoteEntryUniqueKey(remoteInfo: RemoteInfo): string {
const { entry, name } = remoteInfo;
return composeKeyWithSeparator(name, entry);
}

function getRemoteEntryCacheDescriptor(
origin: ModuleFederation,
remoteInfo: RemoteInfo,
Expand Down Expand Up @@ -710,13 +701,3 @@ export async function getRemoteEntry(params: {

return remoteEntryLoading;
}

export function getRemoteInfo(remote: Remote): RemoteInfo {
return {
...remote,
entry: 'entry' in remote ? remote.entry : '',
type: remote.type || DEFAULT_REMOTE_TYPE,
entryGlobalName: remote.entryGlobalName || remote.name,
shareScope: remote.shareScope || DEFAULT_SCOPE,
};
}
21 changes: 21 additions & 0 deletions packages/runtime-core/src/utils/remoteInfo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { composeKeyWithSeparator } from '@module-federation/sdk';
import { DEFAULT_REMOTE_TYPE, DEFAULT_SCOPE } from '../constant';
import type { Remote, RemoteInfo } from '../type';

export function isEsmRemoteType(type: RemoteInfo['type']): boolean {
return type === 'esm' || type === 'module';
}

export function getRemoteEntryUniqueKey(remoteInfo: RemoteInfo): string {
return composeKeyWithSeparator(remoteInfo.name, remoteInfo.entry);
}

export function getRemoteInfo(remote: Remote): RemoteInfo {
return {
...remote,
entry: 'entry' in remote ? remote.entry : '',
type: remote.type || DEFAULT_REMOTE_TYPE,
entryGlobalName: remote.entryGlobalName || remote.name,
shareScope: remote.shareScope || DEFAULT_SCOPE,
};
}
Loading
Loading