From 9215b8b755b4ac7a71c0e5b8eaf9e8dedec388ed Mon Sep 17 00:00:00 2001 From: Kevin Beier Date: Fri, 25 Sep 2026 18:59:15 +0200 Subject: [PATCH 1/6] fix(runtime): keep debug constructors from changing instance behavior --- .changeset/bright-runtimes-load.md | 5 +++ packages/runtime/__tests__/global.spec.ts | 40 ++++++++++++------- .../runtime/__tests__/load-remote.spec.ts | 35 +++++++++++++++- packages/runtime/src/index.ts | 6 +-- 4 files changed, 65 insertions(+), 21 deletions(-) create mode 100644 .changeset/bright-runtimes-load.md diff --git a/.changeset/bright-runtimes-load.md b/.changeset/bright-runtimes-load.md new file mode 100644 index 00000000000..ab5fe53e3ff --- /dev/null +++ b/.changeset/bright-runtimes-load.md @@ -0,0 +1,5 @@ +--- +'@module-federation/runtime': patch +--- + +Keep remote loading available when another app registers a differently optimized runtime in debug mode. diff --git a/packages/runtime/__tests__/global.spec.ts b/packages/runtime/__tests__/global.spec.ts index 73f58bcedf4..16b4aa6910f 100644 --- a/packages/runtime/__tests__/global.spec.ts +++ b/packages/runtime/__tests__/global.spec.ts @@ -1,5 +1,11 @@ import { describe, it, rs, expect } from '@rstest/core'; -import { init, loadRemote, loadShare, loadShareSync } from '../src/index'; +import { + ModuleFederation, + init, + loadRemote, + loadShare, + loadShareSync, +} from '../src/index'; import { getInfoWithoutType } from '@module-federation/runtime-core'; type IsAssignable = [Actual] extends [Expected] @@ -8,20 +14,24 @@ type IsAssignable = [Actual] extends [Expected] type ExpectFalse = T; describe('global', () => { - it('inject mode', () => { - globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__ = rs.fn(); - const injectArgs = { - name: '@federation/inject-mode', - remotes: [], - }; - const GM = init(injectArgs); - expect(GM.constructor).toBe( - globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__, - ); - expect(globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__).toBeCalledWith({ - ...injectArgs, - id: '', - }); + it('keeps the debug constructor available without using it for init', () => { + const previous = globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__; + const debugConstructor = rs.fn() as unknown as typeof ModuleFederation; + globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__ = debugConstructor; + + try { + const instance = init({ + name: '@federation/debug-constructor', + remotes: [], + }); + expect(instance.constructor).toBe(ModuleFederation); + expect(debugConstructor).not.toHaveBeenCalled(); + expect(globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__).toBe( + debugConstructor, + ); + } finally { + globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__ = previous; + } }); it('getInfoWithoutType', () => { diff --git a/packages/runtime/__tests__/load-remote.spec.ts b/packages/runtime/__tests__/load-remote.spec.ts index 92006a990a8..42837068537 100644 --- a/packages/runtime/__tests__/load-remote.spec.ts +++ b/packages/runtime/__tests__/load-remote.spec.ts @@ -1,6 +1,6 @@ import 'whatwg-fetch'; import { assert, describe, it } from '@rstest/core'; -import { ModuleFederation, init } from '../src/index'; +import { ModuleFederation, createInstance, init } from '../src/index'; import { mockRemoteSnapshot } from './mock/utils'; import { matchRemoteWithNameAndExpose } from '@module-federation/runtime-core'; import { @@ -354,6 +354,39 @@ describe('loadRemote', () => { }); describe('loadRemote with manifest.json', () => { + it('loads remotes when another runtime registers a disabled debug constructor', async () => { + class RemoteDisabledFederation extends ModuleFederation { + override async loadRemote(): Promise { + throw new Error( + 'Remote loading is disabled by experiments.optimization.disableRemote.', + ); + } + } + + const previous = Global.__FEDERATION__.__DEBUG_CONSTRUCTOR__; + setGlobalFederationConstructor(RemoteDisabledFederation, true); + + try { + const instance = createInstance({ + name: '@demo/debug-host', + remotes: [ + { + name: '@demo/main', + entry: + 'http://localhost:1111/resources/main/federation-manifest.json', + }, + ], + }); + + expect(instance.constructor).toBe(ModuleFederation); + const remote = await instance.loadRemote<() => string>('@demo/main/say'); + assert(remote); + expect(remote()).toBe('hello world'); + } finally { + setGlobalFederationConstructor(previous, true); + } + }); + it('handles duplicate request to manifest.json', async () => { const FM = new ModuleFederation({ name: '@demo/host', diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 4f3254b33d4..58294e915d3 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -2,7 +2,6 @@ import { ModuleFederation, type UserOptions, CurrentGlobal, - getGlobalFederationConstructor, setGlobalFederationInstance, assert, setGlobalFederationConstructor, @@ -26,10 +25,7 @@ export { export { ModuleFederation }; export function createInstance(options: UserOptions) { - // Retrieve debug constructor - const ModuleFederationConstructor = - getGlobalFederationConstructor() || ModuleFederation; - const instance = new ModuleFederationConstructor({ + const instance = new ModuleFederation({ id: `${options.name}@${options.version || Date.now()}`, ...options, }); From 97034d91c6be21624b659e64d8ac8f297b02d160 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 14:20:46 -0700 Subject: [PATCH 2/6] test(runtime): cover optimized runtime coexistence --- .../__tests__/fixtures/optimized-runtimes.mjs | 106 ++++++++++++++++++ packages/runtime/__tests__/global.spec.ts | 30 +---- .../runtime/__tests__/load-remote.spec.ts | 35 +----- .../__tests__/optimized-runtimes.spec.ts | 24 ++++ 4 files changed, 133 insertions(+), 62 deletions(-) create mode 100644 packages/runtime/__tests__/fixtures/optimized-runtimes.mjs create mode 100644 packages/runtime/__tests__/optimized-runtimes.spec.ts diff --git a/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs b/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs new file mode 100644 index 00000000000..a657b8f4b96 --- /dev/null +++ b/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs @@ -0,0 +1,106 @@ +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { build } from 'esbuild'; + +const runtimeDir = path.resolve(import.meta.dirname, '../..'); +const packageDir = path.resolve(runtimeDir, '..'); +const require = createRequire(path.join(runtimeDir, 'package.json')); + +async function bundleRuntime(disableRemote) { + const result = await build({ + entryPoints: [path.join(runtimeDir, 'src/index.ts')], + bundle: true, + write: false, + platform: 'node', + format: 'cjs', + alias: { + '@module-federation/runtime-core': path.join( + packageDir, + 'runtime-core/src/index.ts', + ), + '@module-federation/error-codes/browser': path.join( + packageDir, + 'error-codes/src/browser.ts', + ), + '@module-federation/error-codes': path.join( + packageDir, + 'error-codes/src/index.ts', + ), + }, + define: { + __VERSION__: '"test"', + FEDERATION_DEBUG: '"true"', + FEDERATION_OPTIMIZE_NO_REMOTE: String(disableRemote), + FEDERATION_OPTIMIZE_NO_SHARED: 'false', + FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: 'false', + FEDERATION_BUILD_IDENTIFIER: JSON.stringify( + disableRemote ? 'disabled-build' : 'full-build', + ), + ENV_TARGET: '"node"', + }, + }); + const module = { exports: {} }; + new Function('require', 'module', 'exports', result.outputFiles[0].text)( + require, + module, + module.exports, + ); + return module.exports; +} + +const server = createServer((_request, response) => { + response.setHeader('Content-Type', 'text/javascript'); + response.end(`module.exports = { + init() {}, + get(expose) { + if (expose !== './value') throw new Error('Unexpected expose: ' + expose); + return () => ({ value: 'remote-value' }); + } + };`); +}); + +try { + const full = await bundleRuntime(false); + const disabled = await bundleRuntime(true); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const entry = `http://127.0.0.1:${address.port}/remoteEntry.js`; + const options = (name) => ({ + name, + remotes: [{ name: 'tiny', entry }], + }); + + const disabledInstance = disabled.createInstance(options('disabled-app')); + const created = full.createInstance(options('full-created')); + const initialized = full.init(options('full-initialized')); + if ( + globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__ !== + disabled.ModuleFederation + ) { + throw new Error( + 'Expected the remote-disabled constructor to register last', + ); + } + + let disabledError; + try { + await disabledInstance.loadRemote('tiny/value'); + } catch (error) { + disabledError = error.message; + } + const createdRemote = await created.loadRemote('tiny/value'); + const initializedRemote = await initialized.loadRemote('tiny/value'); + console.log( + JSON.stringify({ + disabledError, + createdValue: createdRemote?.value, + initializedValue: initializedRemote?.value, + instanceNames: globalThis.__FEDERATION__.__INSTANCES__.map( + (instance) => instance.name, + ), + }), + ); +} finally { + await new Promise((resolve) => server.close(resolve)); +} diff --git a/packages/runtime/__tests__/global.spec.ts b/packages/runtime/__tests__/global.spec.ts index 16b4aa6910f..6a40d2fae5e 100644 --- a/packages/runtime/__tests__/global.spec.ts +++ b/packages/runtime/__tests__/global.spec.ts @@ -1,11 +1,5 @@ -import { describe, it, rs, expect } from '@rstest/core'; -import { - ModuleFederation, - init, - loadRemote, - loadShare, - loadShareSync, -} from '../src/index'; +import { describe, it, expect } from '@rstest/core'; +import { loadRemote, loadShare, loadShareSync } from '../src/index'; import { getInfoWithoutType } from '@module-federation/runtime-core'; type IsAssignable = [Actual] extends [Expected] @@ -14,26 +8,6 @@ type IsAssignable = [Actual] extends [Expected] type ExpectFalse = T; describe('global', () => { - it('keeps the debug constructor available without using it for init', () => { - const previous = globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__; - const debugConstructor = rs.fn() as unknown as typeof ModuleFederation; - globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__ = debugConstructor; - - try { - const instance = init({ - name: '@federation/debug-constructor', - remotes: [], - }); - expect(instance.constructor).toBe(ModuleFederation); - expect(debugConstructor).not.toHaveBeenCalled(); - expect(globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__).toBe( - debugConstructor, - ); - } finally { - globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__ = previous; - } - }); - it('getInfoWithoutType', () => { const snapshot = { '@federation/app1': 1, diff --git a/packages/runtime/__tests__/load-remote.spec.ts b/packages/runtime/__tests__/load-remote.spec.ts index 42837068537..92006a990a8 100644 --- a/packages/runtime/__tests__/load-remote.spec.ts +++ b/packages/runtime/__tests__/load-remote.spec.ts @@ -1,6 +1,6 @@ import 'whatwg-fetch'; import { assert, describe, it } from '@rstest/core'; -import { ModuleFederation, createInstance, init } from '../src/index'; +import { ModuleFederation, init } from '../src/index'; import { mockRemoteSnapshot } from './mock/utils'; import { matchRemoteWithNameAndExpose } from '@module-federation/runtime-core'; import { @@ -354,39 +354,6 @@ describe('loadRemote', () => { }); describe('loadRemote with manifest.json', () => { - it('loads remotes when another runtime registers a disabled debug constructor', async () => { - class RemoteDisabledFederation extends ModuleFederation { - override async loadRemote(): Promise { - throw new Error( - 'Remote loading is disabled by experiments.optimization.disableRemote.', - ); - } - } - - const previous = Global.__FEDERATION__.__DEBUG_CONSTRUCTOR__; - setGlobalFederationConstructor(RemoteDisabledFederation, true); - - try { - const instance = createInstance({ - name: '@demo/debug-host', - remotes: [ - { - name: '@demo/main', - entry: - 'http://localhost:1111/resources/main/federation-manifest.json', - }, - ], - }); - - expect(instance.constructor).toBe(ModuleFederation); - const remote = await instance.loadRemote<() => string>('@demo/main/say'); - assert(remote); - expect(remote()).toBe('hello world'); - } finally { - setGlobalFederationConstructor(previous, true); - } - }); - it('handles duplicate request to manifest.json', async () => { const FM = new ModuleFederation({ name: '@demo/host', diff --git a/packages/runtime/__tests__/optimized-runtimes.spec.ts b/packages/runtime/__tests__/optimized-runtimes.spec.ts new file mode 100644 index 00000000000..75ba8d78f9f --- /dev/null +++ b/packages/runtime/__tests__/optimized-runtimes.spec.ts @@ -0,0 +1,24 @@ +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { describe, expect, it } from '@rstest/core'; + +const execFileAsync = promisify(execFile); + +describe('independently optimized runtimes', () => { + it('loads a remote through full instances when a remote-disabled runtime registers last', async () => { + const { stdout } = await execFileAsync( + process.execPath, + [path.resolve(__dirname, 'fixtures/optimized-runtimes.mjs')], + { cwd: path.resolve(__dirname, '..') }, + ); + + expect(JSON.parse(stdout)).toEqual({ + disabledError: + 'Remote loading is disabled by experiments.optimization.disableRemote.', + createdValue: 'remote-value', + initializedValue: 'remote-value', + instanceNames: ['disabled-app', 'full-created', 'full-initialized'], + }); + }); +}); From 4b1858e7e5c6ceca1011b6426d1f25dc1fb5584b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 14:55:36 -0700 Subject: [PATCH 3/6] fix(runtime): match global instances by build capabilities --- .changeset/bright-runtimes-load.md | 3 +- packages/runtime-core/src/core.ts | 4 + .../__tests__/fixtures/optimized-runtimes.mjs | 234 +++++++++++++++--- .../__tests__/optimized-runtimes.spec.ts | 95 ++++++- packages/runtime/src/utils.ts | 14 +- 5 files changed, 302 insertions(+), 48 deletions(-) diff --git a/.changeset/bright-runtimes-load.md b/.changeset/bright-runtimes-load.md index ab5fe53e3ff..eb09281ab1d 100644 --- a/.changeset/bright-runtimes-load.md +++ b/.changeset/bright-runtimes-load.md @@ -1,5 +1,6 @@ --- '@module-federation/runtime': patch +'@module-federation/runtime-core': patch --- -Keep remote loading available when another app registers a differently optimized runtime in debug mode. +Keep each runtime's constructor and reuse only global instances with matching remote, shared, and snapshot capabilities. This prevents updated runtimes with the same identity from inheriting another build's disabled features. Older runtimes without capability metadata are not reused by updated callers; their own instance lookup remains unchanged. diff --git a/packages/runtime-core/src/core.ts b/packages/runtime-core/src/core.ts index d514911436e..460219bc428 100644 --- a/packages/runtime-core/src/core.ts +++ b/packages/runtime-core/src/core.ts @@ -73,6 +73,10 @@ const USE_SHARED = : true; export class ModuleFederation { + /** @internal Stable feature bits across bundles: remote=1, shared=2, snapshot=4. */ + static readonly runtimeCapabilities = + (USE_REMOTE ? 1 : 0) | (USE_SHARED ? 2 : 0) | (USE_SNAPSHOT ? 4 : 0); + options: Options; hooks = new PluginSystem({ beforeInit: new SyncWaterfallHook<{ diff --git a/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs b/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs index a657b8f4b96..15e7cb9e97c 100644 --- a/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs +++ b/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs @@ -6,8 +6,13 @@ import { build } from 'esbuild'; const runtimeDir = path.resolve(import.meta.dirname, '../..'); const packageDir = path.resolve(runtimeDir, '..'); const require = createRequire(path.join(runtimeDir, 'package.json')); +const scenario = process.argv[2] || 'distinct-last-disabled'; -async function bundleRuntime(disableRemote) { +async function bundleRuntime( + disableRemote, + buildId, + { disableShared = false, disableSnapshot = false } = {}, +) { const result = await build({ entryPoints: [path.join(runtimeDir, 'src/index.ts')], bundle: true, @@ -32,11 +37,9 @@ async function bundleRuntime(disableRemote) { __VERSION__: '"test"', FEDERATION_DEBUG: '"true"', FEDERATION_OPTIMIZE_NO_REMOTE: String(disableRemote), - FEDERATION_OPTIMIZE_NO_SHARED: 'false', - FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: 'false', - FEDERATION_BUILD_IDENTIFIER: JSON.stringify( - disableRemote ? 'disabled-build' : 'full-build', - ), + FEDERATION_OPTIMIZE_NO_SHARED: String(disableShared), + FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: String(disableSnapshot), + FEDERATION_BUILD_IDENTIFIER: JSON.stringify(buildId), ENV_TARGET: '"node"', }, }); @@ -49,7 +52,30 @@ async function bundleRuntime(disableRemote) { return module.exports; } -const server = createServer((_request, response) => { +const server = createServer((request, response) => { + if (request.url === '/mf-manifest.json') { + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ + id: 'manifest-tiny', + name: 'manifest-tiny', + metaData: { + globalName: 'manifestTiny', + publicPath: `http://${request.headers.host}/`, + buildInfo: { buildVersion: '1.0.0' }, + remoteEntry: { + name: 'remoteEntry.js', + path: '', + type: 'commonjs-module', + }, + }, + remotes: [], + shared: [], + exposes: [], + }), + ); + return; + } response.setHeader('Content-Type', 'text/javascript'); response.end(`module.exports = { init() {}, @@ -61,46 +87,182 @@ const server = createServer((_request, response) => { }); try { - const full = await bundleRuntime(false); - const disabled = await bundleRuntime(true); + const fullBuildId = + scenario === 'compatible-reuse' || scenario === 'collision-same-build' + ? 'app@1.0.0' + : scenario === 'version-isolated' || scenario.startsWith('collision-') + ? 'app@2.0.0' + : 'full-app@1.0.0'; + const disabledBuildId = + scenario === 'version-isolated' || scenario.startsWith('collision-') + ? 'app@1.0.0' + : 'disabled-app@1.0.0'; + let full; + let disabled; + if ( + scenario === 'distinct-last-full' || + scenario === 'collision-full-first' + ) { + disabled = await bundleRuntime(true, disabledBuildId); + full = await bundleRuntime(false, fullBuildId); + } else { + full = await bundleRuntime(false, fullBuildId); + const disableShared = scenario === 'collision-shared'; + const disableSnapshot = scenario === 'collision-snapshot'; + disabled = await bundleRuntime( + !disableShared && !disableSnapshot, + disabledBuildId, + { disableShared, disableSnapshot }, + ); + } await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); const entry = `http://127.0.0.1:${address.port}/remoteEntry.js`; - const options = (name) => ({ + const options = (name, version, remoteName = 'tiny') => ({ name, - remotes: [{ name: 'tiny', entry }], + version, + remotes: [{ name: remoteName, entry }], }); - - const disabledInstance = disabled.createInstance(options('disabled-app')); - const created = full.createInstance(options('full-created')); - const initialized = full.init(options('full-initialized')); - if ( - globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__ !== - disabled.ModuleFederation + const value = async (instance, id = 'tiny/value') => + (await instance.loadRemote(id))?.value; + const loadError = async (instance) => { + try { + await instance.loadRemote('tiny/value'); + } catch (error) { + return error.message; + } + return 'Remote loading unexpectedly succeeded'; + }; + let result; + if (scenario === 'distinct-last-full') { + const initialized = full.init(options('full-app')); + const disabledInstance = disabled.init(options('disabled-app')); + result = { + fullValue: await value(initialized), + fullModuleValue: await value(full), + disabledError: await loadError(disabledInstance), + disabledModuleError: await loadError(disabled), + instanceNames: globalThis.__FEDERATION__.__INSTANCES__.map( + (instance) => instance.name, + ), + }; + } else if (scenario === 'version-isolated') { + const disabledInstance = disabled.init(options('app', '1.0.0')); + const initialized = full.init(options('app', '2.0.0')); + result = { + disabledError: await loadError(disabledInstance), + fullValue: await value(initialized), + fullModuleValue: await value(full), + versions: globalThis.__FEDERATION__.__INSTANCES__.map( + (instance) => instance.options.version, + ), + }; + } else if (scenario === 'collision-full-first') { + const initialized = full.init(options('app', '1.0.0')); + const disabledInstance = disabled.init(options('app', '1.0.0')); + result = { + fullValue: await value(initialized), + disabledError: await loadError(disabledInstance), + disabledModuleError: await loadError(disabled), + registeredIds: globalThis.__FEDERATION__.__INSTANCES__.map( + (instance) => instance.options.id, + ), + }; + } else if ( + scenario === 'collision-different-build' || + scenario === 'collision-same-build' ) { - throw new Error( - 'Expected the remote-disabled constructor to register last', + const disabledInstance = disabled.init(options('app', '1.0.0')); + const initialized = full.init(options('app', '1.0.0')); + const repeated = full.init(options('app', '1.0.0')); + result = { + disabledError: await loadError(disabledInstance), + fullValue: await value(initialized), + fullModuleValue: await value(full), + repeatedValue: await value(repeated), + registeredIds: globalThis.__FEDERATION__.__INSTANCES__.map( + (instance) => instance.options.id, + ), + }; + } else if (scenario === 'collision-shared') { + const sharedOptions = { + ...options('app', '1.0.0'), + shared: { + token: { + version: '1.0.0', + get: async () => () => ({ value: 'shared-value' }), + }, + }, + }; + const disabledInstance = disabled.init(sharedOptions); + const initialized = full.init(sharedOptions); + const disabledError = await disabledInstance.loadShare('token').then( + () => 'Shared loading unexpectedly succeeded', + (error) => error.message, ); - } - - let disabledError; - try { - await disabledInstance.loadRemote('tiny/value'); - } catch (error) { - disabledError = error.message; - } - const createdRemote = await created.loadRemote('tiny/value'); - const initializedRemote = await initialized.loadRemote('tiny/value'); - console.log( - JSON.stringify({ + const shared = await initialized.loadShare('token'); + result = { disabledError, - createdValue: createdRemote?.value, - initializedValue: initializedRemote?.value, + sharedValue: shared?.()?.value, + fullValue: await value(initialized), + }; + } else if (scenario === 'collision-snapshot') { + const direct = disabled.init(options('app', '1.0.0')); + const initialized = full.init({ + name: 'app', + version: '1.0.0', + remotes: [ + { + name: 'manifest-tiny', + entry: entry.replace('remoteEntry.js', 'mf-manifest.json'), + }, + ], + }); + result = { + directValue: await value(direct), + manifestValue: await value(initialized, 'manifest-tiny/value'), + moduleValue: await value(full, 'manifest-tiny/value'), + }; + } else if (scenario === 'compatible-reuse') { + const compatible = await bundleRuntime(false, 'app@2.0.0'); + const first = full.init({ + ...options('app', '1.0.0'), + shared: { + token: { + version: '1.0.0', + get: async () => () => ({ value: 'shared-value' }), + }, + }, + }); + const fresh = full.createInstance(options('app', '1.0.0', 'fresh')); + const repeated = compatible.init(options('app', '1.0.0')); + const shared = await repeated.loadShare('token'); + result = { + firstValue: await value(first), + repeatedValue: await value(repeated), + moduleValue: await value(full), + compatibleModuleValue: await value(compatible), + freshValue: await value(fresh, 'fresh/value'), + sharedValue: shared?.()?.value, + instanceCount: globalThis.__FEDERATION__.__INSTANCES__.length, + }; + } else if (scenario === 'distinct-last-disabled') { + const disabledInstance = disabled.createInstance(options('disabled-app')); + const created = full.createInstance(options('full-created')); + const initialized = full.init(options('full-initialized')); + result = { + disabledError: await loadError(disabledInstance), + createdValue: await value(created), + initializedValue: await value(initialized), + moduleValue: await value(full), instanceNames: globalThis.__FEDERATION__.__INSTANCES__.map( (instance) => instance.name, ), - }), - ); + }; + } else { + throw new Error(`Unknown optimized-runtime scenario: ${scenario}`); + } + console.log(JSON.stringify(result)); } finally { await new Promise((resolve) => server.close(resolve)); } diff --git a/packages/runtime/__tests__/optimized-runtimes.spec.ts b/packages/runtime/__tests__/optimized-runtimes.spec.ts index 75ba8d78f9f..e757a8b296c 100644 --- a/packages/runtime/__tests__/optimized-runtimes.spec.ts +++ b/packages/runtime/__tests__/optimized-runtimes.spec.ts @@ -4,21 +4,98 @@ import { promisify } from 'node:util'; import { describe, expect, it } from '@rstest/core'; const execFileAsync = promisify(execFile); +const remoteDisabledError = + 'Remote loading is disabled by experiments.optimization.disableRemote.'; + +async function runScenario(scenario: string) { + const { stdout } = await execFileAsync( + process.execPath, + [path.resolve(__dirname, 'fixtures/optimized-runtimes.mjs'), scenario], + { cwd: path.resolve(__dirname, '..') }, + ); + return JSON.parse(stdout); +} describe('independently optimized runtimes', () => { it('loads a remote through full instances when a remote-disabled runtime registers last', async () => { - const { stdout } = await execFileAsync( - process.execPath, - [path.resolve(__dirname, 'fixtures/optimized-runtimes.mjs')], - { cwd: path.resolve(__dirname, '..') }, - ); - - expect(JSON.parse(stdout)).toEqual({ - disabledError: - 'Remote loading is disabled by experiments.optimization.disableRemote.', + expect(await runScenario('distinct-last-disabled')).toEqual({ + disabledError: remoteDisabledError, createdValue: 'remote-value', initializedValue: 'remote-value', + moduleValue: 'remote-value', instanceNames: ['disabled-app', 'full-created', 'full-initialized'], }); }); + + it('keeps both capabilities when the full runtime registers last', async () => { + expect(await runScenario('distinct-last-full')).toEqual({ + fullValue: 'remote-value', + fullModuleValue: 'remote-value', + disabledError: remoteDisabledError, + disabledModuleError: remoteDisabledError, + instanceNames: ['full-app', 'disabled-app'], + }); + }); + + it('keeps same-name apps separate when their configured app versions differ', async () => { + expect(await runScenario('version-isolated')).toEqual({ + disabledError: remoteDisabledError, + fullValue: 'remote-value', + fullModuleValue: 'remote-value', + versions: ['1.0.0', '2.0.0'], + }); + }); + + it('preserves shared state across compatible bundles while createInstance stays fresh', async () => { + expect(await runScenario('compatible-reuse')).toEqual({ + firstValue: 'remote-value', + repeatedValue: 'remote-value', + moduleValue: 'remote-value', + compatibleModuleValue: 'remote-value', + freshValue: 'remote-value', + sharedValue: 'shared-value', + instanceCount: 2, + }); + }); + + for (const [scenario, registeredIds] of [ + ['collision-different-build', ['app@1.0.0', 'app@2.0.0']], + ['collision-same-build', ['app@1.0.0', 'app@1.0.0']], + ] as const) { + it(`skips incompatible instances with ${scenario}`, async () => { + expect(await runScenario(scenario)).toEqual({ + disabledError: remoteDisabledError, + fullValue: 'remote-value', + fullModuleValue: 'remote-value', + repeatedValue: 'remote-value', + registeredIds, + }); + }); + } + + it('keeps the disabled runtime disabled when the full instance registers first', async () => { + expect(await runScenario('collision-full-first')).toEqual({ + fullValue: 'remote-value', + disabledError: remoteDisabledError, + disabledModuleError: remoteDisabledError, + registeredIds: ['app@2.0.0', 'app@1.0.0'], + }); + }); + + it('keeps shared loading when a same-name instance disables sharing', async () => { + expect(await runScenario('collision-shared')).toEqual({ + disabledError: + 'Shared dependency loading is disabled by experiments.optimization.disableShared.', + sharedValue: 'shared-value', + fullValue: 'remote-value', + }); + }); + + it('loads a manifest when a same-name instance disables snapshot plugins', async () => { + expect(await runScenario('collision-snapshot')).toEqual({ + directValue: 'remote-value', + manifestValue: 'remote-value', + moduleValue: 'remote-value', + }); + }); }); diff --git a/packages/runtime/src/utils.ts b/packages/runtime/src/utils.ts index 5b71cf2d5c2..5f484e53e77 100644 --- a/packages/runtime/src/utils.ts +++ b/packages/runtime/src/utils.ts @@ -1,5 +1,7 @@ -import type { ModuleFederation } from '@module-federation/runtime-core'; -import { CurrentGlobal } from '@module-federation/runtime-core'; +import { + CurrentGlobal, + ModuleFederation, +} from '@module-federation/runtime-core'; // injected by bundler, so it can not use runtime-core stuff export function getBuilderId(): string { @@ -17,6 +19,14 @@ export function getGlobalFederationInstance( const buildId = getBuilderId(); return CurrentGlobal.__FEDERATION__.__INSTANCES__.find( (GMInstance: ModuleFederation) => { + // Older constructors without metadata cannot establish compatibility. + if ( + (GMInstance.constructor as typeof ModuleFederation) + .runtimeCapabilities !== ModuleFederation.runtimeCapabilities + ) { + return false; + } + if (buildId && GMInstance.options.id === buildId) { return true; } From 96c525e22d7a25b98b74b3387eac6506b4b8a856 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 22:25:52 +0000 Subject: [PATCH 4/6] test(runtime): declare each optimized-runtime scenario's bundles explicitly Each scenario now builds its own bundles with named options instead of deriving build ids and flags from the scenario name. Adds two collisions the capability check has to catch: a web-target runtime reused by a Node runtime, and a remote-capable runtime without snapshot plugins. --- .../__tests__/fixtures/optimized-runtimes.mjs | 314 ++++++++++-------- .../__tests__/optimized-runtimes.spec.ts | 9 + 2 files changed, 181 insertions(+), 142 deletions(-) diff --git a/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs b/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs index 15e7cb9e97c..a9cb6b22db1 100644 --- a/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs +++ b/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs @@ -1,3 +1,6 @@ +// Bundles independent copies of @module-federation/runtime, each with its own build-time +// flags, and runs one scenario. Each scenario runs in its own process, so every scenario +// starts with an empty __FEDERATION__ global. import { createServer } from 'node:http'; import { createRequire } from 'node:module'; import path from 'node:path'; @@ -6,13 +9,14 @@ import { build } from 'esbuild'; const runtimeDir = path.resolve(import.meta.dirname, '../..'); const packageDir = path.resolve(runtimeDir, '..'); const require = createRequire(path.join(runtimeDir, 'package.json')); -const scenario = process.argv[2] || 'distinct-last-disabled'; -async function bundleRuntime( - disableRemote, +async function bundle({ buildId, - { disableShared = false, disableSnapshot = false } = {}, -) { + disableRemote = false, + disableShared = false, + disableSnapshot = false, + target = 'node', +}) { const result = await build({ entryPoints: [path.join(runtimeDir, 'src/index.ts')], bundle: true, @@ -40,7 +44,7 @@ async function bundleRuntime( FEDERATION_OPTIMIZE_NO_SHARED: String(disableShared), FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: String(disableSnapshot), FEDERATION_BUILD_IDENTIFIER: JSON.stringify(buildId), - ENV_TARGET: '"node"', + ENV_TARGET: JSON.stringify(target), }, }); const module = { exports: {} }; @@ -86,127 +90,158 @@ const server = createServer((request, response) => { };`); }); -try { - const fullBuildId = - scenario === 'compatible-reuse' || scenario === 'collision-same-build' - ? 'app@1.0.0' - : scenario === 'version-isolated' || scenario.startsWith('collision-') - ? 'app@2.0.0' - : 'full-app@1.0.0'; - const disabledBuildId = - scenario === 'version-isolated' || scenario.startsWith('collision-') - ? 'app@1.0.0' - : 'disabled-app@1.0.0'; - let full; - let disabled; - if ( - scenario === 'distinct-last-full' || - scenario === 'collision-full-first' - ) { - disabled = await bundleRuntime(true, disabledBuildId); - full = await bundleRuntime(false, fullBuildId); - } else { - full = await bundleRuntime(false, fullBuildId); - const disableShared = scenario === 'collision-shared'; - const disableSnapshot = scenario === 'collision-snapshot'; - disabled = await bundleRuntime( - !disableShared && !disableSnapshot, - disabledBuildId, - { disableShared, disableSnapshot }, - ); - } - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const address = server.address(); - const entry = `http://127.0.0.1:${address.port}/remoteEntry.js`; - const options = (name, version, remoteName = 'tiny') => ({ - name, - version, - remotes: [{ name: remoteName, entry }], - }); - const value = async (instance, id = 'tiny/value') => - (await instance.loadRemote(id))?.value; - const loadError = async (instance) => { - try { - await instance.loadRemote('tiny/value'); - } catch (error) { - return error.message; - } - return 'Remote loading unexpectedly succeeded'; - }; - let result; - if (scenario === 'distinct-last-full') { +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const entry = `http://127.0.0.1:${server.address().port}/remoteEntry.js`; + +const options = (name, version, remoteName = 'tiny') => ({ + name, + version, + remotes: [{ name: remoteName, entry }], +}); +const sharedToken = { + token: { + version: '1.0.0', + get: async () => () => ({ value: 'shared-value' }), + }, +}; +const value = async (runtime, id = 'tiny/value') => + (await runtime.loadRemote(id))?.value; +const errorOf = (promise, what) => + promise.then( + () => `${what} unexpectedly succeeded`, + (error) => error.message, + ); +const loadError = (runtime) => + errorOf( + Promise.resolve().then(() => runtime.loadRemote('tiny/value')), + 'Remote loading', + ); +const instances = (pick) => globalThis.__FEDERATION__.__INSTANCES__.map(pick); + +const scenarios = { + async 'distinct-last-disabled'() { + const full = await bundle({ buildId: 'full-app@1.0.0' }); + const disabled = await bundle({ + buildId: 'disabled-app@1.0.0', + disableRemote: true, + }); + const disabledInstance = disabled.createInstance(options('disabled-app')); + const created = full.createInstance(options('full-created')); + const initialized = full.init(options('full-initialized')); + return { + disabledError: await loadError(disabledInstance), + createdValue: await value(created), + initializedValue: await value(initialized), + moduleValue: await value(full), + instanceNames: instances((instance) => instance.name), + }; + }, + + async 'distinct-last-full'() { + const disabled = await bundle({ + buildId: 'disabled-app@1.0.0', + disableRemote: true, + }); + const full = await bundle({ buildId: 'full-app@1.0.0' }); const initialized = full.init(options('full-app')); const disabledInstance = disabled.init(options('disabled-app')); - result = { + return { fullValue: await value(initialized), fullModuleValue: await value(full), disabledError: await loadError(disabledInstance), disabledModuleError: await loadError(disabled), - instanceNames: globalThis.__FEDERATION__.__INSTANCES__.map( - (instance) => instance.name, - ), + instanceNames: instances((instance) => instance.name), }; - } else if (scenario === 'version-isolated') { + }, + + async 'version-isolated'() { + const full = await bundle({ buildId: 'app@2.0.0' }); + const disabled = await bundle({ + buildId: 'app@1.0.0', + disableRemote: true, + }); const disabledInstance = disabled.init(options('app', '1.0.0')); const initialized = full.init(options('app', '2.0.0')); - result = { + return { disabledError: await loadError(disabledInstance), fullValue: await value(initialized), fullModuleValue: await value(full), - versions: globalThis.__FEDERATION__.__INSTANCES__.map( - (instance) => instance.options.version, - ), + versions: instances((instance) => instance.options.version), }; - } else if (scenario === 'collision-full-first') { + }, + + async 'compatible-reuse'() { + const full = await bundle({ buildId: 'app@1.0.0' }); + const compatible = await bundle({ buildId: 'app@2.0.0' }); + const first = full.init({ + ...options('app', '1.0.0'), + shared: sharedToken, + }); + const fresh = full.createInstance(options('app', '1.0.0', 'fresh')); + const repeated = compatible.init(options('app', '1.0.0')); + const shared = await repeated.loadShare('token'); + return { + firstValue: await value(first), + repeatedValue: await value(repeated), + moduleValue: await value(full), + compatibleModuleValue: await value(compatible), + freshValue: await value(fresh, 'fresh/value'), + sharedValue: shared?.()?.value, + instanceCount: globalThis.__FEDERATION__.__INSTANCES__.length, + }; + }, + + 'collision-different-build': () => + disabledThenFull({ fullBuildId: 'app@2.0.0' }), + + 'collision-same-build': () => disabledThenFull({ fullBuildId: 'app@1.0.0' }), + + // Without snapshot plugins, only the remote capability tells the two builds apart. + 'collision-remote-without-snapshot': () => + disabledThenFull({ fullBuildId: 'app@1.0.0', disableSnapshot: true }), + + async 'collision-full-first'() { + const full = await bundle({ buildId: 'app@2.0.0' }); + const disabled = await bundle({ + buildId: 'app@1.0.0', + disableRemote: true, + }); const initialized = full.init(options('app', '1.0.0')); const disabledInstance = disabled.init(options('app', '1.0.0')); - result = { + return { fullValue: await value(initialized), disabledError: await loadError(disabledInstance), disabledModuleError: await loadError(disabled), - registeredIds: globalThis.__FEDERATION__.__INSTANCES__.map( - (instance) => instance.options.id, - ), - }; - } else if ( - scenario === 'collision-different-build' || - scenario === 'collision-same-build' - ) { - const disabledInstance = disabled.init(options('app', '1.0.0')); - const initialized = full.init(options('app', '1.0.0')); - const repeated = full.init(options('app', '1.0.0')); - result = { - disabledError: await loadError(disabledInstance), - fullValue: await value(initialized), - fullModuleValue: await value(full), - repeatedValue: await value(repeated), - registeredIds: globalThis.__FEDERATION__.__INSTANCES__.map( - (instance) => instance.options.id, - ), - }; - } else if (scenario === 'collision-shared') { - const sharedOptions = { - ...options('app', '1.0.0'), - shared: { - token: { - version: '1.0.0', - get: async () => () => ({ value: 'shared-value' }), - }, - }, + registeredIds: instances((instance) => instance.options.id), }; + }, + + async 'collision-shared'() { + const full = await bundle({ buildId: 'app@2.0.0' }); + const disabled = await bundle({ + buildId: 'app@1.0.0', + disableShared: true, + }); + const sharedOptions = { ...options('app', '1.0.0'), shared: sharedToken }; const disabledInstance = disabled.init(sharedOptions); const initialized = full.init(sharedOptions); - const disabledError = await disabledInstance.loadShare('token').then( - () => 'Shared loading unexpectedly succeeded', - (error) => error.message, - ); const shared = await initialized.loadShare('token'); - result = { - disabledError, + return { + disabledError: await errorOf( + disabledInstance.loadShare('token'), + 'Shared loading', + ), sharedValue: shared?.()?.value, fullValue: await value(initialized), }; - } else if (scenario === 'collision-snapshot') { + }, + + async 'collision-snapshot'() { + const full = await bundle({ buildId: 'app@2.0.0' }); + const disabled = await bundle({ + buildId: 'app@1.0.0', + disableSnapshot: true, + }); const direct = disabled.init(options('app', '1.0.0')); const initialized = full.init({ name: 'app', @@ -218,51 +253,46 @@ try { }, ], }); - result = { + return { directValue: await value(direct), manifestValue: await value(initialized, 'manifest-tiny/value'), moduleValue: await value(full, 'manifest-tiny/value'), }; - } else if (scenario === 'compatible-reuse') { - const compatible = await bundleRuntime(false, 'app@2.0.0'); - const first = full.init({ - ...options('app', '1.0.0'), - shared: { - token: { - version: '1.0.0', - get: async () => () => ({ value: 'shared-value' }), - }, - }, - }); - const fresh = full.createInstance(options('app', '1.0.0', 'fresh')); - const repeated = compatible.init(options('app', '1.0.0')); - const shared = await repeated.loadShare('token'); - result = { - firstValue: await value(first), - repeatedValue: await value(repeated), - moduleValue: await value(full), - compatibleModuleValue: await value(compatible), - freshValue: await value(fresh, 'fresh/value'), - sharedValue: shared?.()?.value, - instanceCount: globalThis.__FEDERATION__.__INSTANCES__.length, - }; - } else if (scenario === 'distinct-last-disabled') { - const disabledInstance = disabled.createInstance(options('disabled-app')); - const created = full.createInstance(options('full-created')); - const initialized = full.init(options('full-initialized')); - result = { - disabledError: await loadError(disabledInstance), - createdValue: await value(created), - initializedValue: await value(initialized), - moduleValue: await value(full), - instanceNames: globalThis.__FEDERATION__.__INSTANCES__.map( - (instance) => instance.name, - ), + }, + + // A web-target runtime loads entries with the DOM loader, which cannot run in Node. + async 'collision-target'() { + const web = await bundle({ buildId: 'app@1.0.0', target: 'web' }); + const node = await bundle({ buildId: 'app@1.0.0' }); + const webInstance = web.init(options('app', '1.0.0')); + const nodeInstance = node.init(options('app', '1.0.0')); + return { + separate: webInstance !== nodeInstance, + nodeValue: await value(nodeInstance), + nodeModuleValue: await value(node), }; - } else { - throw new Error(`Unknown optimized-runtime scenario: ${scenario}`); - } - console.log(JSON.stringify(result)); + }, +}; + +async function disabledThenFull({ fullBuildId, disableSnapshot = false }) { + const full = await bundle({ buildId: fullBuildId, disableSnapshot }); + const disabled = await bundle({ buildId: 'app@1.0.0', disableRemote: true }); + const disabledInstance = disabled.init(options('app', '1.0.0')); + const initialized = full.init(options('app', '1.0.0')); + const repeated = full.init(options('app', '1.0.0')); + return { + disabledError: await loadError(disabledInstance), + fullValue: await value(initialized), + fullModuleValue: await value(full), + repeatedValue: await value(repeated), + registeredIds: instances((instance) => instance.options.id), + }; +} + +const scenario = process.argv[2]; +try { + if (!scenarios[scenario]) throw new Error(`Unknown scenario: ${scenario}`); + console.log(JSON.stringify(await scenarios[scenario]())); } finally { await new Promise((resolve) => server.close(resolve)); } diff --git a/packages/runtime/__tests__/optimized-runtimes.spec.ts b/packages/runtime/__tests__/optimized-runtimes.spec.ts index e757a8b296c..a830f386eda 100644 --- a/packages/runtime/__tests__/optimized-runtimes.spec.ts +++ b/packages/runtime/__tests__/optimized-runtimes.spec.ts @@ -61,6 +61,7 @@ describe('independently optimized runtimes', () => { for (const [scenario, registeredIds] of [ ['collision-different-build', ['app@1.0.0', 'app@2.0.0']], ['collision-same-build', ['app@1.0.0', 'app@1.0.0']], + ['collision-remote-without-snapshot', ['app@1.0.0', 'app@1.0.0']], ] as const) { it(`skips incompatible instances with ${scenario}`, async () => { expect(await runScenario(scenario)).toEqual({ @@ -91,6 +92,14 @@ describe('independently optimized runtimes', () => { }); }); + it('keeps a Node runtime separate from a same-identity web runtime', async () => { + expect(await runScenario('collision-target')).toEqual({ + separate: true, + nodeValue: 'remote-value', + nodeModuleValue: 'remote-value', + }); + }); + it('loads a manifest when a same-name instance disables snapshot plugins', async () => { expect(await runScenario('collision-snapshot')).toEqual({ directValue: 'remote-value', From 869e211b967b9aa44fa445d2440132acfc53f799 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 22:25:52 +0000 Subject: [PATCH 5/6] fix(runtime-core): match reused instances by build target as well A web-target build loads remote entries with the DOM loader and ships a loadScriptNode stub, so a Node runtime that reused its instance could not load remotes. The capability check now includes the build target, and the capabilities are a readable string stored on each instance, so a runtime that composes its capabilities per instance can describe itself. --- .changeset/bright-runtimes-load.md | 2 +- packages/runtime-core/src/core.ts | 20 +++++++++++++++++--- packages/runtime/src/utils.ts | 5 ++--- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/.changeset/bright-runtimes-load.md b/.changeset/bright-runtimes-load.md index eb09281ab1d..264e780252a 100644 --- a/.changeset/bright-runtimes-load.md +++ b/.changeset/bright-runtimes-load.md @@ -3,4 +3,4 @@ '@module-federation/runtime-core': patch --- -Keep each runtime's constructor and reuse only global instances with matching remote, shared, and snapshot capabilities. This prevents updated runtimes with the same identity from inheriting another build's disabled features. Older runtimes without capability metadata are not reused by updated callers; their own instance lookup remains unchanged. +`createInstance` now constructs the runtime's own `ModuleFederation` class, not a debug constructor that another bundle set on the global. `init` reuses a global instance only when it supports the same remote loading, shared loading, snapshot plugins, and build target as the calling runtime. Updated runtimes do not reuse instances created by older runtimes. diff --git a/packages/runtime-core/src/core.ts b/packages/runtime-core/src/core.ts index 460219bc428..375c3c9036b 100644 --- a/packages/runtime-core/src/core.ts +++ b/packages/runtime-core/src/core.ts @@ -53,6 +53,7 @@ import { formatShareConfigs } from './utils/share'; declare const FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: boolean; declare const FEDERATION_OPTIMIZE_NO_REMOTE: boolean; declare const FEDERATION_OPTIMIZE_NO_SHARED: boolean; +declare const ENV_TARGET: 'web' | 'node'; type BridgeHookContext = object; type BridgeHookResult = { @@ -72,10 +73,23 @@ const USE_SHARED = ? !FEDERATION_OPTIMIZE_NO_SHARED : true; +// What this build of the runtime can do. `init` in another bundle reuses an instance only +// when this string matches its own: remote loading, shared loading, snapshot plugins, and +// the entry loader the build target selects. +const RUNTIME_CAPABILITIES = [ + USE_REMOTE && 'remote', + USE_SHARED && 'shared', + USE_REMOTE && USE_SNAPSHOT && 'snapshot', + typeof ENV_TARGET !== 'undefined' ? ENV_TARGET : 'universal', +] + .filter(Boolean) + .join(','); + export class ModuleFederation { - /** @internal Stable feature bits across bundles: remote=1, shared=2, snapshot=4. */ - static readonly runtimeCapabilities = - (USE_REMOTE ? 1 : 0) | (USE_SHARED ? 2 : 0) | (USE_SNAPSHOT ? 4 : 0); + /** @internal */ + static readonly runtimeCapabilities = RUNTIME_CAPABILITIES; + /** @internal Read by other bundles, so it lives on the instance. */ + readonly runtimeCapabilities = RUNTIME_CAPABILITIES; options: Options; hooks = new PluginSystem({ diff --git a/packages/runtime/src/utils.ts b/packages/runtime/src/utils.ts index 5f484e53e77..8f2c8494aa2 100644 --- a/packages/runtime/src/utils.ts +++ b/packages/runtime/src/utils.ts @@ -19,10 +19,9 @@ export function getGlobalFederationInstance( const buildId = getBuilderId(); return CurrentGlobal.__FEDERATION__.__INSTANCES__.find( (GMInstance: ModuleFederation) => { - // Older constructors without metadata cannot establish compatibility. + // An instance from an older runtime has no capabilities to compare, so it is not reused. if ( - (GMInstance.constructor as typeof ModuleFederation) - .runtimeCapabilities !== ModuleFederation.runtimeCapabilities + GMInstance.runtimeCapabilities !== ModuleFederation.runtimeCapabilities ) { return false; } From 5978eedef1f4544b5ca8b896ae381443ca3eb3a7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 23:26:29 +0000 Subject: [PATCH 6/6] fix(runtime-core): expose runtimeCapabilities through a static getter for Metro --- packages/runtime-core/src/core.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/runtime-core/src/core.ts b/packages/runtime-core/src/core.ts index 375c3c9036b..6a80a7137b5 100644 --- a/packages/runtime-core/src/core.ts +++ b/packages/runtime-core/src/core.ts @@ -86,8 +86,10 @@ const RUNTIME_CAPABILITIES = [ .join(','); export class ModuleFederation { - /** @internal */ - static readonly runtimeCapabilities = RUNTIME_CAPABILITIES; + /** @internal A getter, because a static field compiles to a static block that Metro's Babel rejects. */ + static get runtimeCapabilities(): string { + return RUNTIME_CAPABILITIES; + } /** @internal Read by other bundles, so it lives on the instance. */ readonly runtimeCapabilities = RUNTIME_CAPABILITIES;