From 804192f168d59478dd1351713343eb90cd5ffd20 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 22 Jul 2026 21:40:03 +0000 Subject: [PATCH 01/27] feat(mf): initialize ordered share scopes at runtime --- .../embed_federation_runtime_module.rs | 2 - .../src/container/remote_module.rs | 4 +- .../src/container/remote_runtime_module.rs | 36 +- .../rspack/src/container/ContainerPlugin.ts | 9 +- .../src/container/ContainerReferencePlugin.ts | 31 +- .../src/container/ModuleFederationPlugin.ts | 29 +- .../runtime/moduleFederationDefaultRuntime.js | 270 +++++++++++-- .../ModuleFederationRuntime.test.js | 382 ++++++++++++++++++ .../errors.js | 3 + .../index.js | 3 + .../rspack.config.js | 19 + .../index.js | 3 + .../rspack.config.js | 18 + .../errors.js | 3 + .../index.js | 3 + .../rspack.config.js | 19 + .../errors.js | 3 + .../index.js | 3 + .../rspack.config.js | 18 + 19 files changed, 790 insertions(+), 68 deletions(-) create mode 100644 tests/rspack-test/ModuleFederationRuntime.test.js create mode 100644 tests/rspack-test/configCases/container-1-0/share-scope-array-empty-validation/errors.js create mode 100644 tests/rspack-test/configCases/container-1-0/share-scope-array-empty-validation/index.js create mode 100644 tests/rspack-test/configCases/container-1-0/share-scope-array-empty-validation/rspack.config.js create mode 100644 tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-singleton/index.js create mode 100644 tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-singleton/rspack.config.js create mode 100644 tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation-shared/errors.js create mode 100644 tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation-shared/index.js create mode 100644 tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation-shared/rspack.config.js create mode 100644 tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation/errors.js create mode 100644 tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation/index.js create mode 100644 tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation/rspack.config.js diff --git a/crates/rspack_plugin_mf/src/container/embed_federation_runtime_module.rs b/crates/rspack_plugin_mf/src/container/embed_federation_runtime_module.rs index 45d2a50d6ca4..c95ac6bc93af 100644 --- a/crates/rspack_plugin_mf/src/container/embed_federation_runtime_module.rs +++ b/crates/rspack_plugin_mf/src/container/embed_federation_runtime_module.rs @@ -12,7 +12,6 @@ use rspack_core::{ use rspack_error::Result; use super::module_federation_runtime_plugin::ModuleFederationRuntimeExperimentsOptions; - #[cacheable] #[derive(Debug, Default, Clone, Hash, PartialEq, Eq)] pub struct EmbedFederationRuntimeModuleOptions { @@ -115,7 +114,6 @@ impl RuntimeModule for EmbedFederationRuntimeModule { module_executions.push_str(&module_str); module_executions.push('\n'); } - if self.options.experiments.async_startup { let entry_chunk_ids = compilation .build_chunk_graph_artifact diff --git a/crates/rspack_plugin_mf/src/container/remote_module.rs b/crates/rspack_plugin_mf/src/container/remote_module.rs index 095049db6d5f..9e5bc7b3ebbf 100644 --- a/crates/rspack_plugin_mf/src/container/remote_module.rs +++ b/crates/rspack_plugin_mf/src/container/remote_module.rs @@ -60,8 +60,8 @@ impl RemoteModule { blocks: Default::default(), dependencies: Default::default(), identifier: ModuleIdentifier::from(format!( - "remote ({}) {} {}", - share_scope.key(), + "remote {} {} {}", + share_scope.identifier_fragment(), external_requests.join(" "), internal_request )), diff --git a/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs b/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs index 998184fdd725..b2f215e728ef 100644 --- a/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs +++ b/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs @@ -2,9 +2,9 @@ use std::sync::LazyLock; use rspack_collections::Identifiable; use rspack_core::{ - ChunkGraph, Compilation, DependenciesBlock, ModuleId, RuntimeGlobals, RuntimeModule, - RuntimeModuleGenerateContext, RuntimeModuleRuntimeRequirements, RuntimeModuleStage, - RuntimeTemplate, SourceType, impl_runtime_module, + ChunkGraph, Compilation, DependenciesBlock, ExternalModule, ModuleId, RuntimeGlobals, + RuntimeModule, RuntimeModuleGenerateContext, RuntimeModuleRuntimeRequirements, + RuntimeModuleStage, RuntimeTemplate, SourceType, extract_url_and_global, impl_runtime_module, }; use rspack_plugin_runtime::extract_runtime_globals_from_ejs; use rustc_hash::FxHashMap; @@ -98,6 +98,26 @@ impl RuntimeModule for RemoteRuntimeModule { let external_module = module_graph .get_module_by_dependency_id(&dep) .expect("should have module"); + let remote_info = self + .enhanced + .then(|| { + external_module + .downcast_ref::() + .map(|external_module| { + let external_type = external_module.get_external_type().as_str(); + let name = if external_type == "script" { + extract_url_and_global(external_module.get_request().primary()) + .map_or("", |url_and_global| url_and_global.global) + } else { + "" + }; + RemoteInfo { + external_type, + name, + } + }) + }) + .flatten(); let external_module_id = ChunkGraph::get_module_id( &compilation.module_ids_artifact, external_module.identifier(), @@ -111,6 +131,7 @@ impl RuntimeModule for RemoteRuntimeModule { name, external_module_id, remote_name: &m.remote_key, + remote_info, }, ); } @@ -158,6 +179,15 @@ struct RemoteData<'a> { name: &'a str, external_module_id: &'a ModuleId, remote_name: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + remote_info: Option>, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct RemoteInfo<'a> { + external_type: &'a str, + name: &'a str, } #[derive(Debug, Serialize)] diff --git a/packages/rspack/src/container/ContainerPlugin.ts b/packages/rspack/src/container/ContainerPlugin.ts index cce6425f5246..ea91b8fac78d 100644 --- a/packages/rspack/src/container/ContainerPlugin.ts +++ b/packages/rspack/src/container/ContainerPlugin.ts @@ -10,7 +10,7 @@ import { import type { Compiler } from '../Compiler'; import type { EntryRuntime, FilenameTemplate, LibraryOptions } from '../config'; import { parseOptions } from '../container/options'; -import { type ShareScope, validateShareScope } from '../sharing/SharePlugin'; +import { normalizeShareScope, type ShareScope } from '../sharing/SharePlugin'; import { ShareRuntimePlugin } from '../sharing/ShareRuntimePlugin'; export type ContainerPluginOptions = { @@ -39,9 +39,12 @@ export class ContainerPlugin extends RspackBuiltinPlugin { constructor(options: ContainerPluginOptions) { super(); - const shareScope = options.shareScope || 'default'; const enhanced = options.enhanced ?? false; - validateShareScope(shareScope, enhanced, 'ContainerPlugin'); + const shareScope = normalizeShareScope( + options.shareScope || 'default', + enhanced, + 'ContainerPlugin', + ); this._options = { name: options.name, shareScope, diff --git a/packages/rspack/src/container/ContainerReferencePlugin.ts b/packages/rspack/src/container/ContainerReferencePlugin.ts index 895cbaffeba6..a9e4d4a6373f 100644 --- a/packages/rspack/src/container/ContainerReferencePlugin.ts +++ b/packages/rspack/src/container/ContainerReferencePlugin.ts @@ -10,7 +10,7 @@ import { import { ExternalsPlugin } from '../builtin-plugin/ExternalsPlugin'; import type { Compiler } from '../Compiler'; import type { ExternalsType } from '../config'; -import { type ShareScope, validateShareScope } from '../sharing/SharePlugin'; +import { normalizeShareScope, type ShareScope } from '../sharing/SharePlugin'; import { ShareRuntimePlugin } from '../sharing/ShareRuntimePlugin'; import { parseOptions } from './options'; @@ -38,33 +38,30 @@ export class ContainerReferencePlugin extends RspackBuiltinPlugin { constructor(options: ContainerReferencePluginOptions) { super(); const enhanced = options.enhanced ?? false; - if (options.shareScope) { - validateShareScope( - options.shareScope, - enhanced, - 'ContainerReferencePlugin', - ); - } + const shareScope = options.shareScope + ? normalizeShareScope( + options.shareScope, + enhanced, + 'ContainerReferencePlugin', + ) + : undefined; const remotes = parseOptions( options.remotes, (item) => ({ external: Array.isArray(item) ? item : [item], - shareScope: options.shareScope || 'default', + shareScope: shareScope || 'default', }), (item) => ({ external: Array.isArray(item.external) ? item.external : [item.external], - shareScope: item.shareScope || options.shareScope || 'default', + shareScope: normalizeShareScope( + item.shareScope || shareScope || 'default', + enhanced, + 'ContainerReferencePlugin', + ), }), ); - for (const [, config] of remotes) { - validateShareScope( - config.shareScope, - enhanced, - 'ContainerReferencePlugin', - ); - } this._options = { remoteType: options.remoteType, remotes, diff --git a/packages/rspack/src/container/ModuleFederationPlugin.ts b/packages/rspack/src/container/ModuleFederationPlugin.ts index e11df6443075..7d93e2efd9a3 100644 --- a/packages/rspack/src/container/ModuleFederationPlugin.ts +++ b/packages/rspack/src/container/ModuleFederationPlugin.ts @@ -3,7 +3,10 @@ import { resolve } from 'node:path'; import type { Compiler } from '../Compiler'; import { createCompilerRuntimeGlobals } from '../RuntimeGlobals'; import type { ExternalsType } from '../config'; -import type { ShareFallback } from '../sharing/IndependentSharedPlugin'; +import type { + ShareFallback, + ShareFallbackVariants, +} from '../sharing/IndependentSharedPlugin'; import type { SharedConfig, ShareScope } from '../sharing/SharePlugin'; import { TreeShakingSharedPlugin } from '../sharing/TreeShakingSharedPlugin'; import { isRequiredVersion } from '../sharing/utils'; @@ -71,17 +74,31 @@ export class ModuleFederationPlugin { }) : undefined; runtimeVirtualPlugin?.apply(compiler); - const updateRuntimeShareFallbacks = (buildAssets: ShareFallback) => { + const updateRuntimeShareFallbacks = ( + _buildAssets: ShareFallback, + variants: ShareFallbackVariants, + ) => { if (!runtimeVirtualPath || !runtimeVirtualPlugin) { return; } + const runtimeBuildAssets = Object.fromEntries( + Object.entries(variants).map(([shareKey, entries]) => [ + shareKey, + entries.map(({ entry, version, globalName }) => [ + entry, + version, + globalName, + ]), + ]), + ) as ShareFallback; runtimeVirtualPlugin.writeModule( runtimeVirtualPath, getDefaultEntryRuntimeSource( paths, this._options, compiler, - buildAssets, + runtimeBuildAssets, + variants, ), ); }; @@ -340,6 +357,7 @@ function getDefaultEntryRuntimeSource( options: ModuleFederationPluginOptions, compiler: Compiler, treeShakingShareFallbacks?: ShareFallback, + treeShakingShareFallbackVariants?: ShareFallbackVariants, ) { const runtimePlugins = getRuntimePlugins(options); const remoteInfos = getRemoteInfos(options); @@ -408,6 +426,9 @@ function getDefaultEntryRuntimeSource( `const __module_federation_share_fallbacks__ = ${JSON.stringify( treeShakingShareFallbacks, )}`, + `const __module_federation_share_fallback_variants__ = ${JSON.stringify( + treeShakingShareFallbackVariants, + )}`, `const __module_federation_library_type__ = ${JSON.stringify(libraryType)}`, runtimeSource, ].join(';'); @@ -437,6 +458,7 @@ function getDefaultEntryRuntime( options: ModuleFederationPluginOptions, compiler: Compiler, treeShakingShareFallbacks?: ShareFallback, + treeShakingShareFallbackVariants?: ShareFallbackVariants, ) { return `${MF_RUNTIME_LOADER}!=!data:text/javascript,${encodeURIComponent( getDefaultEntryRuntimeSource( @@ -444,6 +466,7 @@ function getDefaultEntryRuntime( options, compiler, treeShakingShareFallbacks, + treeShakingShareFallbackVariants, ), )}`; } diff --git a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js index 0434834ad0a0..76b095d84433 100644 --- a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js +++ b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js @@ -5,6 +5,7 @@ var __module_federation_bundler_runtime__, __module_federation_container_name__, __module_federation_share_strategy__, __module_federation_share_fallbacks__, + __module_federation_share_fallback_variants__, __module_federation_library_type__; export default function () { const runtimeRequire = __module_federation_runtime_require__; @@ -43,8 +44,75 @@ export default function () { const consumesLoadinginstalledModules = {}; const initializeSharingInitPromises = []; const initializeSharingInitTokens = {}; + const arrayInitializedExternals = new WeakMap(); const containerShareScope = runtimeRequire.initializeExposesData?.shareScope; + const additionalContainerInitScopes = + runtimeRequire.initializeSharingData?.additionalInitScopes; + + const createArrayScopeRequire = (shareScopes) => { + const wrapExternal = (external) => { + if (!external) return external; + if (external.then) return external.then(wrapExternal); + const init = external.init; + if (typeof init !== 'function') return external; + const facade = Object.create(external); + Object.defineProperty(facade, 'init', { + value: (shareScope, initScope, remoteEntryInitOptions) => { + if (arrayInitializedExternals.has(external)) { + return arrayInitializedExternals.get(external); + } + arrayInitializedExternals.set(external, undefined); + const result = init.call( + external, + runtimeRequire.S[shareScopes[0]], + initScope, + remoteEntryInitOptions, + ); + arrayInitializedExternals.set(external, result); + return result; + }, + }); + return facade; + }; + return new Proxy(runtimeRequire, { + apply(target, thisArg, args) { + return wrapExternal(Reflect.apply(target, thisArg, args)); + }, + }); + }; + + const enableArrayRemoteShareScopes = (instance) => { + const sharedHandler = instance?.sharedHandler; + const initializeSharing = sharedHandler?.initializeSharing; + if ( + typeof initializeSharing !== 'function' || + initializeSharing.__rspack_share_scope_array_wrapper__ + ) { + return; + } + const arrayAwareInitializeSharing = function (shareScope, options) { + const arrayRemotes = []; + for (const remote of instance.options.remotes) { + if ( + Array.isArray(remote.shareScope) && + remote.shareScope.includes(shareScope) + ) { + arrayRemotes.push([remote, remote.shareScope]); + remote.shareScope = shareScope; + } + } + try { + return initializeSharing.call(this, shareScope, options); + } finally { + for (const [remote, shareScopes] of arrayRemotes) { + remote.shareScope = shareScopes; + } + } + }; + arrayAwareInitializeSharing.__rspack_share_scope_array_wrapper__ = true; + sharedHandler.initializeSharing = arrayAwareInitializeSharing; + }; for (const key in __module_federation_bundler_runtime__) { runtimeRequire.federation[key] = @@ -62,6 +130,40 @@ export default function () { () => __module_federation_share_fallbacks__, ); const sharedFallback = runtimeRequire.federation.sharedFallback; + const getSharedFallbackKey = (moduleId, data) => { + const variants = + __module_federation_share_fallback_variants__?.[data.shareKey]; + if (!variants) return data.shareKey; + const expectedScopes = Array.isArray(data.shareScope) + ? data.shareScope + : [data.shareScope || 'default']; + const matchesScope = (variant) => { + const scopes = Array.isArray(variant.shareScope) + ? variant.shareScope + : [variant.shareScope || 'default']; + return ( + scopes.length === expectedScopes.length && + scopes.every((scope, index) => scope === expectedScopes[index]) + ); + }; + let matches = variants.filter( + (variant) => variant.layer === data.layer && matchesScope(variant), + ); + if (matches.length === 0 && data.layer !== undefined) { + matches = variants.filter( + (variant) => variant.layer === undefined && matchesScope(variant), + ); + } + const requestMatches = (variant) => + !variant.import || variant.import === data.import; + matches = matches.filter(requestMatches); + if (matches.length === 0) return; + const fallbackKey = `${data.shareKey}\0${moduleId}`; + sharedFallback[fallbackKey] = matches.map( + ({ entry, version, globalName }) => [entry, version, globalName], + ); + return fallbackKey; + }; early( runtimeRequire.federation, 'consumesLoadingModuleToHandlerMapping', @@ -70,17 +172,19 @@ export default function () { for (let [moduleId, data] of Object.entries( consumesLoadingModuleToConsumeDataMapping, )) { + const fallbackKey = getSharedFallbackKey(moduleId, data); consumesLoadingModuleToHandlerMapping[moduleId] = { - getter: sharedFallback - ? runtimeRequire.federation.bundlerRuntime?.getSharedFallbackGetter( - { - shareKey: data.shareKey, - factory: data.fallback, - webpackRequire: runtimeRequire, - libraryType: runtimeRequire.federation.libraryType, - }, - ) - : data.fallback, + getter: + sharedFallback && fallbackKey + ? runtimeRequire.federation.bundlerRuntime?.getSharedFallbackGetter( + { + shareKey: fallbackKey, + factory: data.fallback, + webpackRequire: runtimeRequire, + libraryType: runtimeRequire.federation.libraryType, + }, + ) + : data.fallback, treeShakingGetter: sharedFallback ? data.fallback : undefined, shareInfo: { shareConfig: { @@ -89,8 +193,11 @@ export default function () { strictVersion: data.strictVersion, singleton: data.singleton, eager: data.eager, + layer: data.layer, }, - scope: [data.shareScope], + scope: Array.isArray(data.shareScope) + ? data.shareScope + : [data.shareScope || 'default'], }, shareKey: data.shareKey, treeShaking: runtimeRequire.federation.sharedFallback @@ -104,7 +211,6 @@ export default function () { return consumesLoadingModuleToHandlerMapping; }, ); - early(runtimeRequire.federation, 'initOptions', () => ({})); early( runtimeRequire.federation.initOptions, @@ -132,6 +238,7 @@ export default function () { requiredVersion, strictVersion, treeShakingMode, + layer, } = stage; const shareConfig = {}; const isValidValue = function (val) { @@ -149,6 +256,9 @@ export default function () { if (isValidValue(strictVersion)) { shareConfig.strictVersion = strictVersion; } + if (isValidValue(layer)) { + shareConfig.layer = layer; + } const options = { version, scope: [scope], @@ -228,9 +338,15 @@ export default function () { for (let [id, remoteData] of Object.entries( remotesLoadingModuleIdToRemoteDataMapping, )) { + const existingInfos = + __module_federation_remote_infos__[remoteData.remoteName] || []; const info = - __module_federation_remote_infos__[remoteData.remoteName]; - if (info) idToRemoteMap[id] = info; + existingInfos.length > 0 + ? existingInfos + : remoteData.remoteInfo + ? [remoteData.remoteInfo] + : []; + if (info.length > 0) idToRemoteMap[id] = info; } return idToRemoteMap; }, @@ -254,37 +370,114 @@ export default function () { webpackRequire: runtimeRequire, }), ); - override(runtimeRequire.f, 'consumes', (chunkId, promises) => - runtimeRequire.federation.bundlerRuntime.consumes({ - chunkId, - promises, - chunkMapping: consumesLoadingChunkMapping, - moduleToHandlerMapping: - runtimeRequire.federation.consumesLoadingModuleToHandlerMapping, - installedModules: consumesLoadinginstalledModules, - webpackRequire: runtimeRequire, - }), - ); - override(runtimeRequire, 'I', (name, initScope) => - runtimeRequire.federation.bundlerRuntime.I({ + const initializeConsumeShareScopes = (moduleIds) => { + if (!moduleIds?.length) return []; + const initPromises = []; + const initializedScopes = new Set(); + for (const moduleId of moduleIds) { + const shareScope = + consumesLoadingModuleToConsumeDataMapping[moduleId]?.shareScope || + 'default'; + const scopeKey = JSON.stringify( + Array.isArray(shareScope) ? shareScope : [shareScope], + ); + if (initializedScopes.has(scopeKey)) continue; + initializedScopes.add(scopeKey); + const initialized = runtimeRequire.I(shareScope, []); + if (initialized?.then) initPromises.push(initialized); + } + return initPromises; + }; + override(runtimeRequire.f, 'consumes', (chunkId, promises) => { + const consume = (targetPromises) => + runtimeRequire.federation.bundlerRuntime.consumes({ + chunkId, + promises: targetPromises, + chunkMapping: consumesLoadingChunkMapping, + moduleToHandlerMapping: + runtimeRequire.federation.consumesLoadingModuleToHandlerMapping, + installedModules: consumesLoadinginstalledModules, + webpackRequire: runtimeRequire, + }); + const initPromises = initializeConsumeShareScopes( + consumesLoadingChunkMapping[chunkId], + ); + if (initPromises.length === 0) return consume(promises); + promises.push( + Promise.all(initPromises).then(() => { + const consumePromises = []; + consume(consumePromises); + return Promise.all(consumePromises); + }), + ); + }); + override(runtimeRequire, 'I', (name, initScope) => { + const webpackRequire = Array.isArray(name) + ? createArrayScopeRequire(name) + : runtimeRequire; + return runtimeRequire.federation.bundlerRuntime.I({ shareScopeName: name, initScope, initPromises: initializeSharingInitPromises, initTokens: initializeSharingInitTokens, - webpackRequire: runtimeRequire, - }), - ); + webpackRequire, + }); + }); override( runtimeRequire, 'initContainer', - (shareScope, initScope, remoteEntryInitOptions) => - runtimeRequire.federation.bundlerRuntime.initContainerEntry({ - shareScope, - initScope, - remoteEntryInitOptions, - shareScopeKey: containerShareScope, - webpackRequire: runtimeRequire, - }), + (shareScope, initScope, remoteEntryInitOptions) => { + let options = remoteEntryInitOptions; + const additionalScopes = []; + if ( + additionalContainerInitScopes?.length && + options?.shareScopeMap && + !Array.isArray(options.shareScopeKeys) + ) { + const primaryScope = options.shareScopeKeys || 'default'; + const shareScopeKeys = [primaryScope]; + const containerScopes = Array.isArray(containerShareScope) + ? containerShareScope + : [containerShareScope || 'default']; + for (const scope of additionalContainerInitScopes) { + if (scope === primaryScope) continue; + shareScopeKeys.push(scope); + if (!containerScopes.includes(scope)) additionalScopes.push(scope); + } + const descriptors = Object.getOwnPropertyDescriptors(options); + descriptors.shareScopeKeys = { + configurable: true, + enumerable: + Object.getOwnPropertyDescriptor(options, 'shareScopeKeys') + ?.enumerable ?? true, + value: shareScopeKeys, + writable: true, + }; + options = Object.create(Object.getPrototypeOf(options), descriptors); + } + const result = + runtimeRequire.federation.bundlerRuntime.initContainerEntry({ + shareScope, + initScope, + remoteEntryInitOptions: options, + shareScopeKey: containerShareScope, + webpackRequire: runtimeRequire, + }); + if (additionalScopes.length === 0) return result; + const initializeAdditionalScopes = () => + Promise.all( + additionalScopes.flatMap((scope) => + runtimeRequire.federation.instance.initializeSharing(scope, { + from: 'build', + strategy: + runtimeRequire.federation.instance.options.shareStrategy, + }), + ), + ); + return result?.then + ? Promise.resolve(result).then(initializeAdditionalScopes) + : initializeAdditionalScopes(); + }, ); override(runtimeRequire, 'getContainer', (module, getScope) => { var moduleMap = runtimeRequire.initializeExposesData.moduleMap; @@ -304,6 +497,7 @@ export default function () { runtimeRequire.federation.bundlerRuntime.init({ webpackRequire: runtimeRequire, }); + enableArrayRemoteShareScopes(runtimeRequire.federation.instance); if (runtimeRequire.consumesLoadingData?.initialConsumes) { runtimeRequire.federation.bundlerRuntime.installInitialConsumes({ diff --git a/tests/rspack-test/ModuleFederationRuntime.test.js b/tests/rspack-test/ModuleFederationRuntime.test.js new file mode 100644 index 000000000000..cf0d0c3fb322 --- /dev/null +++ b/tests/rspack-test/ModuleFederationRuntime.test.js @@ -0,0 +1,382 @@ +const runtime = require('../../packages/rspack/src/runtime/moduleFederationDefaultRuntime.js').default; +const { + bundlerRuntime, +} = require('@module-federation/runtime-tools/webpack-bundler-runtime'); + +const magicNames = [ + '__module_federation_bundler_runtime__', + '__module_federation_runtime_plugins__', + '__module_federation_remote_infos__', + '__module_federation_container_name__', + '__module_federation_share_strategy__', + '__module_federation_share_fallbacks__', + '__module_federation_share_fallback_variants__', + '__module_federation_library_type__', + '__module_federation_runtime_require__', +]; + +function createRuntime({ + external, + externalType = 'commonjs-module', + remoteShareScope = ['primary', 'secondary'], + containerShareScope = 'container-custom', + hasContainer = true, + sharedFallback, + sharedFallbackVariants, + consumeData, + additionalInitScopes = [], + scopeToSharingDataMapping = {}, +} = {}) { + const shareScopeMap = { + primary: { tag: 'primary' }, + secondary: { tag: 'secondary' }, + 'host-custom': { tag: 'host-custom' }, + }; + const runtimeRequire = () => external; + Object.assign(runtimeRequire, { + S: shareScopeMap, + f: {}, + I() {}, + federation: {}, + initializeSharingData: { + scopeToSharingDataMapping, + additionalInitScopes, + }, + remotesLoadingData: { + chunkMapping: {}, + moduleIdToRemoteDataMapping: { + remote: { + shareScope: remoteShareScope, + name: './module', + externalModuleId: 'external', + remoteName: 'remote', + }, + }, + }, + }); + if (consumeData) { + runtimeRequire.consumesLoadingData = { + chunkMapping: {}, + moduleIdToConsumeDataMapping: { consume: consumeData }, + }; + } + if (hasContainer) { + runtimeRequire.initContainer = () => {}; + runtimeRequire.initializeExposesData = { + moduleMap: {}, + shareScope: containerShareScope, + }; + } + + const matchedScopes = []; + const initializedScopes = []; + const initContainerCalls = []; + const remote = { name: 'remote', shareScope: remoteShareScope }; + const instance = { + name: 'host', + options: { + remotes: [remote], + shareStrategy: 'version-first', + }, + shareScopeMap, + sharedHandler: { + initializeSharing(shareScope) { + initializedScopes.push(shareScope); + if (instance.options.remotes.some(item => item.shareScope === shareScope)) { + matchedScopes.push(shareScope); + } + return []; + }, + }, + initializeSharing(shareScope, options) { + return this.sharedHandler.initializeSharing(shareScope, options); + }, + }; + const localBundlerRuntime = { + ...bundlerRuntime, + init: () => instance, + getSharedFallbackGetter: ({ shareKey }) => shareKey, + initContainerEntry: options => { + initContainerCalls.push(options); + }, + }; + const importedBundlerRuntime = { + ...localBundlerRuntime, + bundlerRuntime: localBundlerRuntime, + }; + const remoteInfos = { + remote: [ + { + alias: 'remote', + name: externalType === 'script' ? 'remote' : undefined, + externalType, + shareScope: remoteShareScope, + }, + ], + }; + const instantiate = new Function( + ...magicNames, + `return (${runtime.toString()});`, + ); + instantiate( + importedBundlerRuntime, + [], + remoteInfos, + 'host', + 'version-first', + sharedFallback, + sharedFallbackVariants, + 'commonjs-module', + runtimeRequire, + )(); + + return { + initContainerCalls, + initializedScopes, + instance, + matchedScopes, + remote, + runtimeRequire, + shareScopeMap, + }; +} + +describe('module federation default runtime share scopes', () => { + it('preserves scalar custom-scope container initialization', () => { + const { initContainerCalls, instance, runtimeRequire, shareScopeMap } = + createRuntime({ remoteShareScope: 'host-custom' }); + const remoteEntryInitOptions = { shareScopeKeys: 'host-custom' }; + Object.defineProperty(remoteEntryInitOptions, 'shareScopeMap', { + value: shareScopeMap, + }); + + runtimeRequire.initContainer( + shareScopeMap['host-custom'], + [], + remoteEntryInitOptions, + ); + + expect(initContainerCalls).toHaveLength(1); + expect(initContainerCalls[0]).toMatchObject({ + shareScope: shareScopeMap['host-custom'], + shareScopeKey: 'container-custom', + remoteEntryInitOptions, + }); + expect(instance.options.remotes[0].shareScope).toBe('host-custom'); + expect(runtimeRequire.initContainer).toHaveLength(3); + }); + + it('expands container init only by declared additional scopes', async () => { + const { initContainerCalls, runtimeRequire, shareScopeMap } = createRuntime({ + remoteShareScope: 'host-custom', + additionalInitScopes: ['secondary'], + scopeToSharingDataMapping: { + 'host-custom': [], + secondary: [], + }, + }); + const remoteEntryInitOptions = { shareScopeKeys: 'host-custom' }; + Object.defineProperty(remoteEntryInitOptions, 'shareScopeMap', { + value: shareScopeMap, + }); + + await runtimeRequire.initContainer( + shareScopeMap['host-custom'], + [], + remoteEntryInitOptions, + ); + + expect(initContainerCalls).toHaveLength(1); + expect( + initContainerCalls[0].remoteEntryInitOptions.shareScopeKeys, + ).toEqual(['host-custom', 'secondary']); + expect(remoteEntryInitOptions.shareScopeKeys).toBe('host-custom'); + expect( + Object.getOwnPropertyDescriptor( + initContainerCalls[0].remoteEntryInitOptions, + 'shareScopeMap', + ).enumerable, + ).toBe(false); + }); + + it('does not post-initialize scopes owned by the container', async () => { + const { initializedScopes, runtimeRequire, shareScopeMap } = createRuntime({ + containerShareScope: 'secondary', + additionalInitScopes: ['secondary'], + }); + const remoteEntryInitOptions = { + shareScopeKeys: 'primary', + shareScopeMap, + }; + + await runtimeRequire.initContainer( + shareScopeMap.primary, + [], + remoteEntryInitOptions, + ); + + expect(initializedScopes).toEqual([]); + }); + + it.each(['commonjs-module', 'module'])( + 'initializes a legacy %s remote once with the primary array scope', + async externalType => { + const calls = []; + const legacyContainer = { + init(shareScope, initScope) { + calls.push([shareScope, initScope]); + }, + }; + const external = + externalType === 'module' + ? Promise.resolve(legacyContainer) + : legacyContainer; + const { runtimeRequire, shareScopeMap } = createRuntime({ + external, + externalType, + }); + + await runtimeRequire.I(['primary', 'secondary'], []); + + expect(calls).toHaveLength(1); + expect(calls[0]).toHaveLength(2); + expect(calls[0][0]).toBe(shareScopeMap.primary); + }, + ); + + it('matches every configured script-remote scope during version-first initialization', async () => { + const { matchedScopes, remote, runtimeRequire } = createRuntime({ + externalType: 'script', + hasContainer: false, + }); + + await runtimeRequire.I(['primary', 'secondary'], []); + + expect(matchedScopes).toEqual(['primary', 'secondary']); + expect(remote.shareScope).toEqual(['primary', 'secondary']); + }); + + it('passes the full ordered scope capability to an enhanced remote once', async () => { + const calls = []; + const enhancedContainer = { + init(...args) { + calls.push(args); + }, + }; + const { runtimeRequire, shareScopeMap } = createRuntime({ + external: enhancedContainer, + }); + + await runtimeRequire.I(['primary', 'secondary'], []); + + expect(calls).toHaveLength(1); + expect(calls[0]).toHaveLength(3); + expect(calls[0][0]).toBe(shareScopeMap.primary); + expect(calls[0][2].shareScopeKeys).toEqual(['primary', 'secondary']); + }); + + it('initializes a frozen legacy container without proxying its exports', async () => { + const calls = []; + const external = Object.freeze({ + init(...args) { + calls.push(args); + }, + }); + const { runtimeRequire, shareScopeMap } = createRuntime({ external }); + + await runtimeRequire.I(['primary', 'secondary'], []); + + expect(calls).toHaveLength(1); + expect(calls[0][0]).toBe(shareScopeMap.primary); + }); + + it('does not retry a legacy container whose init throws synchronously', async () => { + let calls = 0; + const external = { + init() { + calls += 1; + throw new Error('legacy init failed'); + }, + }; + const { runtimeRequire } = createRuntime({ external }); + + await runtimeRequire.I(['primary', 'secondary'], []); + + expect(calls).toBe(1); + }); + + it('selects tree-shaking fallbacks by scope and layer identity', () => { + const sharedFallback = { + react: [ + ['legacy.js', '1.0.0', 'legacy'], + ['server.js', '1.0.0', 'server'], + ], + }; + const { runtimeRequire } = createRuntime({ + sharedFallback, + sharedFallbackVariants: { + react: [ + { + entry: 'legacy.js', + version: '1.0.0', + globalName: 'legacy', + shareScope: 'primary', + import: 'react', + }, + { + entry: 'server.js', + version: '1.0.0', + globalName: 'server', + shareScope: 'primary', + layer: 'server', + import: 'react-server', + }, + ], + }, + consumeData: { + shareKey: 'react', + shareScope: 'primary', + layer: 'server', + import: 'react-server', + }, + }); + + const fallbackKey = + runtimeRequire.federation.consumesLoadingModuleToHandlerMapping.consume + .getter; + expect(fallbackKey).toBe('react\0consume'); + expect(runtimeRequire.federation.sharedFallback[fallbackKey]).toEqual([ + ['server.js', '1.0.0', 'server'], + ]); + expect(sharedFallback.react).toHaveLength(2); + }); + + it('uses the consume fallback when no tree-shaking variant matches', () => { + const fallback = () => 'local'; + const { runtimeRequire } = createRuntime({ + sharedFallback: { react: [['client.js', '1.0.0', 'client']] }, + sharedFallbackVariants: { + react: [ + { + entry: 'client.js', + version: '1.0.0', + globalName: 'client', + shareScope: 'client', + import: 'react-client', + }, + ], + }, + consumeData: { + shareKey: 'react', + shareScope: 'server', + import: 'react-server', + fallback, + }, + }); + + expect( + runtimeRequire.federation.consumesLoadingModuleToHandlerMapping.consume + .getter, + ).toBe(fallback); + }); +}); diff --git a/tests/rspack-test/configCases/container-1-0/share-scope-array-empty-validation/errors.js b/tests/rspack-test/configCases/container-1-0/share-scope-array-empty-validation/errors.js new file mode 100644 index 000000000000..64125177c1cd --- /dev/null +++ b/tests/rspack-test/configCases/container-1-0/share-scope-array-empty-validation/errors.js @@ -0,0 +1,3 @@ +module.exports = [ + /\[ContainerPlugin\] shareScope array must contain at least one entry/, +]; diff --git a/tests/rspack-test/configCases/container-1-0/share-scope-array-empty-validation/index.js b/tests/rspack-test/configCases/container-1-0/share-scope-array-empty-validation/index.js new file mode 100644 index 000000000000..f168a498a260 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-0/share-scope-array-empty-validation/index.js @@ -0,0 +1,3 @@ +it('should reject an empty shareScope array', () => { + expect(true).toBe(true); +}); diff --git a/tests/rspack-test/configCases/container-1-0/share-scope-array-empty-validation/rspack.config.js b/tests/rspack-test/configCases/container-1-0/share-scope-array-empty-validation/rspack.config.js new file mode 100644 index 000000000000..558d5ae24524 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-0/share-scope-array-empty-validation/rspack.config.js @@ -0,0 +1,19 @@ +const { ModuleFederationPluginV1: ModuleFederationPlugin } = + require('@rspack/core').container; + +/** @type {import('@rspack/core').Configuration} */ +module.exports = { + mode: 'development', + entry: './index.js', + plugins: [ + new ModuleFederationPlugin({ + name: 'empty_scope_validation', + filename: 'remoteEntry.js', + exposes: { + './exposed': './index.js', + }, + shareScope: [], + enhanced: true, + }), + ], +}; diff --git a/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-singleton/index.js b/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-singleton/index.js new file mode 100644 index 000000000000..6585dd04efbb --- /dev/null +++ b/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-singleton/index.js @@ -0,0 +1,3 @@ +it('should normalize a legacy singleton shareScope array', () => { + expect(true).toBe(true); +}); diff --git a/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-singleton/rspack.config.js b/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-singleton/rspack.config.js new file mode 100644 index 000000000000..f0cb236709e0 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-singleton/rspack.config.js @@ -0,0 +1,18 @@ +const { ModuleFederationPluginV1: ModuleFederationPlugin } = + require('@rspack/core').container; + +/** @type {import('@rspack/core').Configuration} */ +module.exports = { + mode: 'development', + entry: './index.js', + plugins: [ + new ModuleFederationPlugin({ + name: 'legacy_singleton_scope', + filename: 'remoteEntry.js', + exposes: { + './exposed': './index.js', + }, + shareScope: ['default'], + }), + ], +}; diff --git a/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation-shared/errors.js b/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation-shared/errors.js new file mode 100644 index 000000000000..a6edaa3511cd --- /dev/null +++ b/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation-shared/errors.js @@ -0,0 +1,3 @@ +module.exports = [ + /\[SharePlugin\] shareScope as an array with multiple entries requires enhanced=true/, +]; diff --git a/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation-shared/index.js b/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation-shared/index.js new file mode 100644 index 000000000000..c0bcff6dab3a --- /dev/null +++ b/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation-shared/index.js @@ -0,0 +1,3 @@ +it('should fail before build for legacy multi-scope shared shareScope', () => { + expect(true).toBe(true); +}); diff --git a/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation-shared/rspack.config.js b/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation-shared/rspack.config.js new file mode 100644 index 000000000000..ccec971aef24 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation-shared/rspack.config.js @@ -0,0 +1,19 @@ +const { ModuleFederationPluginV1: ModuleFederationPlugin } = + require('@rspack/core').container; + +/** @type {import('@rspack/core').Configuration} */ +module.exports = { + mode: 'development', + entry: './index.js', + plugins: [ + new ModuleFederationPlugin({ + name: 'legacy_multi_scope_validation_shared', + shared: { + react: { + import: 'react', + shareScope: ['default', 'ssr'], + }, + }, + }), + ], +}; diff --git a/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation/errors.js b/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation/errors.js new file mode 100644 index 000000000000..210ea45b311e --- /dev/null +++ b/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation/errors.js @@ -0,0 +1,3 @@ +module.exports = [ + /\[ContainerPlugin\] shareScope as an array with multiple entries requires enhanced=true/, +]; diff --git a/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation/index.js b/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation/index.js new file mode 100644 index 000000000000..24326efb576b --- /dev/null +++ b/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation/index.js @@ -0,0 +1,3 @@ +it('should fail before build for legacy multi-scope shareScope', () => { + expect(true).toBe(true); +}); diff --git a/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation/rspack.config.js b/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation/rspack.config.js new file mode 100644 index 000000000000..23c2d67dd361 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-0/share-scope-array-legacy-validation/rspack.config.js @@ -0,0 +1,18 @@ +const { ModuleFederationPluginV1: ModuleFederationPlugin } = + require('@rspack/core').container; + +/** @type {import('@rspack/core').Configuration} */ +module.exports = { + mode: 'development', + entry: './index.js', + plugins: [ + new ModuleFederationPlugin({ + name: 'legacy_multi_scope_validation', + filename: 'remoteEntry.js', + exposes: { + './exposed': './index.js', + }, + shareScope: ['default', 'ssr'], + }), + ], +}; From fefd61d32eca5152ca2c3d1a0749f2d40fc43af3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 28 Jul 2026 00:42:44 +0000 Subject: [PATCH 02/27] perf(mf): compact enhanced remote metadata --- .../src/container/remote_runtime_module.rs | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs b/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs index 86268b00f200..dcd44524cee0 100644 --- a/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs +++ b/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs @@ -102,26 +102,27 @@ impl RuntimeModule for RemoteRuntimeModule { let external_module = module_graph .get_module_by_dependency_id(&dep) .expect("should have module"); - let remote_info = self - .enhanced - .then(|| { - external_module - .downcast_ref::() - .map(|external_module| { - let external_type = external_module.get_external_type().as_str(); - let name = if external_type == "script" { - extract_url_and_global(external_module.get_request().primary()) - .map_or("", |url_and_global| url_and_global.global) - } else { - "" - }; - RemoteInfo { - external_type, - name, - } - }) - }) - .flatten(); + let remote_info = if self.enhanced { + if let Some(external_module) = external_module.downcast_ref::() { + let external_type = external_module.get_external_type().as_str(); + let name = if external_type == "script" { + match extract_url_and_global(external_module.get_request().primary()) { + Ok(url_and_global) => url_and_global.global, + Err(_) => "", + } + } else { + "" + }; + Some(RemoteInfo { + external_type, + name, + }) + } else { + None + } + } else { + None + }; let external_module_id = ChunkGraph::get_module_id( &compilation.module_ids_artifact, external_module.identifier(), From 2119bde04fc9dd369b93efa462e9038590d1d85e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 29 Jul 2026 00:29:46 +0000 Subject: [PATCH 03/27] perf(mf): trim remote metadata serialization --- .../src/container/remote_runtime_module.rs | 12 ++++++------ crates/rspack_plugin_mf/src/lib.rs | 6 ++---- .../sharing/shared_used_exports_optimizer_plugin.rs | 1 + 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs b/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs index dcd44524cee0..12c140087568 100644 --- a/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs +++ b/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs @@ -13,7 +13,7 @@ use serde::Serialize; use super::remote_module::RemoteModule; use crate::{ ShareScope, - utils::{json_stringify, runtime_require_scope_name, runtime_require_scope_requirement}, + utils::{runtime_require_scope_name, runtime_require_scope_requirement}, }; static REMOTES_LOADING_TEMPLATE: &str = include_str!("./remotesLoading.ejs"); @@ -170,14 +170,14 @@ impl RuntimeModule for RemoteRuntimeModule { {remotes_loading_impl} "#, require_name = runtime_require_scope_name(runtime_template), - chunk_mapping = json_stringify(&chunk_to_remotes_mapping), - id_to_remote_data_mapping = json_stringify(&id_to_remote_data_mapping), + chunk_mapping = simd_json::to_string(&chunk_to_remotes_mapping).unwrap(), + id_to_remote_data_mapping = simd_json::to_string(&id_to_remote_data_mapping).unwrap(), remotes_loading_impl = remotes_loading_impl, )) } } -#[derive(Debug, Serialize)] +#[derive(Serialize)] #[serde(rename_all = "camelCase")] struct RemoteData<'a> { share_scope: ShareScopeField<'a>, @@ -188,14 +188,14 @@ struct RemoteData<'a> { remote_info: Option>, } -#[derive(Debug, Serialize)] +#[derive(Serialize)] #[serde(rename_all = "camelCase")] struct RemoteInfo<'a> { external_type: &'a str, name: &'a str, } -#[derive(Debug, Serialize)] +#[derive(Serialize)] #[serde(untagged)] enum ShareScopeField<'a> { Single(&'a str), diff --git a/crates/rspack_plugin_mf/src/lib.rs b/crates/rspack_plugin_mf/src/lib.rs index b81a9c64ad57..51f8da9c0481 100644 --- a/crates/rspack_plugin_mf/src/lib.rs +++ b/crates/rspack_plugin_mf/src/lib.rs @@ -173,16 +173,14 @@ pub use sharing::{ }; mod utils { - use std::fmt; - use rspack_core::{ Compilation, ModuleCodeTemplate, RuntimeCodeTemplate, RuntimeGlobals, RuntimeVariable, runtime_mode::RuntimeMode, }; use serde::Serialize; - pub fn json_stringify(v: &T) -> String { - simd_json::to_string(v).unwrap_or_else(|e| panic!("{e}: {v:?} should able to json stringify")) + pub fn json_stringify(v: &T) -> String { + simd_json::to_string(v).unwrap() } pub fn module_identifier_namespace(runtime_mode: RuntimeMode) -> &'static str { diff --git a/crates/rspack_plugin_mf/src/sharing/shared_used_exports_optimizer_plugin.rs b/crates/rspack_plugin_mf/src/sharing/shared_used_exports_optimizer_plugin.rs index 875913df71bc..311836af93d1 100644 --- a/crates/rspack_plugin_mf/src/sharing/shared_used_exports_optimizer_plugin.rs +++ b/crates/rspack_plugin_mf/src/sharing/shared_used_exports_optimizer_plugin.rs @@ -27,6 +27,7 @@ use crate::{ manifest::StatsRoot, }; +#[inline(always)] fn referenced_exports_for_output<'a>( shared_referenced_exports: &'a FxHashMap>, share_key: &str, From 2c0d2b7210567c6bfc0c258e986be95301336480 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 29 Jul 2026 00:40:48 +0000 Subject: [PATCH 04/27] fix(mf): scope remote init deduplication --- .../src/runtime/moduleFederationDefaultRuntime.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js index 88e665f302fa..d92bfec84021 100644 --- a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js +++ b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js @@ -59,10 +59,16 @@ export default function () { const facade = Object.create(external); Object.defineProperty(facade, 'init', { value: (shareScope, initScope, remoteEntryInitOptions) => { - if (arrayInitializedExternals.has(external)) { - return arrayInitializedExternals.get(external); + let initializedScopes = arrayInitializedExternals.get(external); + if (!initializedScopes) { + initializedScopes = new Map(); + arrayInitializedExternals.set(external, initializedScopes); } - arrayInitializedExternals.set(external, undefined); + const scopesKey = JSON.stringify(shareScopes); + if (initializedScopes.has(scopesKey)) { + return initializedScopes.get(scopesKey); + } + initializedScopes.set(scopesKey, undefined); const result = remoteEntryInitOptions === undefined ? init.call( @@ -76,7 +82,7 @@ export default function () { initScope, remoteEntryInitOptions, ); - arrayInitializedExternals.set(external, result); + initializedScopes.set(scopesKey, result); return result; }, }); From 982bd52e2c92d677ecfad1e7f6a648b0ffef6392 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 29 Jul 2026 00:48:38 +0000 Subject: [PATCH 05/27] fix(mf): initialize scopes for eager consumes --- .../runtime/moduleFederationDefaultRuntime.js | 3 +++ .../ModuleFederationRuntime.test.js | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js index d92bfec84021..d20bc6c4bc22 100644 --- a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js +++ b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js @@ -518,6 +518,9 @@ export default function () { enableArrayRemoteShareScopes(runtimeRequire.federation.instance); if (runtimeRequire.consumesLoadingData?.initialConsumes) { + initializeConsumeShareScopes( + runtimeRequire.consumesLoadingData.initialConsumes, + ); runtimeRequire.federation.bundlerRuntime.installInitialConsumes({ webpackRequire: runtimeRequire, installedModules: consumesLoadinginstalledModules, diff --git a/tests/rspack-test/ModuleFederationRuntime.test.js b/tests/rspack-test/ModuleFederationRuntime.test.js index cf0d0c3fb322..b4b4ffe492fc 100644 --- a/tests/rspack-test/ModuleFederationRuntime.test.js +++ b/tests/rspack-test/ModuleFederationRuntime.test.js @@ -24,6 +24,7 @@ function createRuntime({ sharedFallback, sharedFallbackVariants, consumeData, + initialConsumes, additionalInitScopes = [], scopeToSharingDataMapping = {}, } = {}) { @@ -35,8 +36,10 @@ function createRuntime({ const runtimeRequire = () => external; Object.assign(runtimeRequire, { S: shareScopeMap, + c: {}, f: {}, I() {}, + m: {}, federation: {}, initializeSharingData: { scopeToSharingDataMapping, @@ -57,6 +60,7 @@ function createRuntime({ if (consumeData) { runtimeRequire.consumesLoadingData = { chunkMapping: {}, + initialConsumes, moduleIdToConsumeDataMapping: { consume: consumeData }, }; } @@ -91,6 +95,7 @@ function createRuntime({ initializeSharing(shareScope, options) { return this.sharedHandler.initializeSharing(shareScope, options); }, + registerShared() {}, }; const localBundlerRuntime = { ...bundlerRuntime, @@ -275,6 +280,28 @@ describe('module federation default runtime share scopes', () => { expect(calls[0][2].shareScopeKeys).toEqual(['primary', 'secondary']); }); + it('initializes ordered scopes before installing initial consumes', () => { + const calls = []; + const enhancedContainer = { + init(...args) { + calls.push(args); + }, + }; + const { runtimeRequire, shareScopeMap } = createRuntime({ + external: enhancedContainer, + consumeData: { + shareKey: 'react', + shareScope: ['primary', 'secondary'], + }, + initialConsumes: ['consume'], + }); + + expect(calls).toHaveLength(1); + expect(calls[0][0]).toBe(shareScopeMap.primary); + expect(calls[0][2].shareScopeKeys).toEqual(['primary', 'secondary']); + expect(runtimeRequire.m.consume).toBeTypeOf('function'); + }); + it('initializes a frozen legacy container without proxying its exports', async () => { const calls = []; const external = Object.freeze({ From 6917dd6c838fd1a1706c0d9b761d1d9f3092dbdf Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 29 Jul 2026 01:19:49 +0000 Subject: [PATCH 06/27] fix(mf): satisfy serialization lint --- .../rspack_plugin_mf/src/container/remote_runtime_module.rs | 6 ++++-- crates/rspack_plugin_mf/src/lib.rs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs b/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs index 12c140087568..770d11ecbe70 100644 --- a/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs +++ b/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs @@ -170,8 +170,10 @@ impl RuntimeModule for RemoteRuntimeModule { {remotes_loading_impl} "#, require_name = runtime_require_scope_name(runtime_template), - chunk_mapping = simd_json::to_string(&chunk_to_remotes_mapping).unwrap(), - id_to_remote_data_mapping = simd_json::to_string(&id_to_remote_data_mapping).unwrap(), + chunk_mapping = + simd_json::to_string(&chunk_to_remotes_mapping).expect("valid remote chunk mapping"), + id_to_remote_data_mapping = + simd_json::to_string(&id_to_remote_data_mapping).expect("valid remote data"), remotes_loading_impl = remotes_loading_impl, )) } diff --git a/crates/rspack_plugin_mf/src/lib.rs b/crates/rspack_plugin_mf/src/lib.rs index 51f8da9c0481..62e5c6757b49 100644 --- a/crates/rspack_plugin_mf/src/lib.rs +++ b/crates/rspack_plugin_mf/src/lib.rs @@ -180,7 +180,7 @@ mod utils { use serde::Serialize; pub fn json_stringify(v: &T) -> String { - simd_json::to_string(v).unwrap() + simd_json::to_string(v).expect("valid JSON") } pub fn module_identifier_namespace(runtime_mode: RuntimeMode) -> &'static str { From d540607a386e2577e064eff67b3d3d0db378fd3c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 29 Jul 2026 01:39:14 +0000 Subject: [PATCH 07/27] fix(mf): await initial consume scopes --- .../runtime/moduleFederationDefaultRuntime.js | 22 +++++++----- .../ModuleFederationRuntime.test.js | 36 +++++++++++++++++-- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js index d20bc6c4bc22..8ec005948721 100644 --- a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js +++ b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js @@ -518,16 +518,22 @@ export default function () { enableArrayRemoteShareScopes(runtimeRequire.federation.instance); if (runtimeRequire.consumesLoadingData?.initialConsumes) { - initializeConsumeShareScopes( + const installInitialConsumes = () => + runtimeRequire.federation.bundlerRuntime.installInitialConsumes({ + webpackRequire: runtimeRequire, + installedModules: consumesLoadinginstalledModules, + initialConsumes: runtimeRequire.consumesLoadingData.initialConsumes, + moduleToHandlerMapping: + runtimeRequire.federation.consumesLoadingModuleToHandlerMapping, + }); + const initPromises = initializeConsumeShareScopes( runtimeRequire.consumesLoadingData.initialConsumes, ); - runtimeRequire.federation.bundlerRuntime.installInitialConsumes({ - webpackRequire: runtimeRequire, - installedModules: consumesLoadinginstalledModules, - initialConsumes: runtimeRequire.consumesLoadingData.initialConsumes, - moduleToHandlerMapping: - runtimeRequire.federation.consumesLoadingModuleToHandlerMapping, - }); + if (initPromises.length === 0) { + installInitialConsumes(); + } else { + Promise.all(initPromises).then(installInitialConsumes); + } } } } diff --git a/tests/rspack-test/ModuleFederationRuntime.test.js b/tests/rspack-test/ModuleFederationRuntime.test.js index b4b4ffe492fc..5f3ed9ef2ea8 100644 --- a/tests/rspack-test/ModuleFederationRuntime.test.js +++ b/tests/rspack-test/ModuleFederationRuntime.test.js @@ -146,6 +146,14 @@ function createRuntime({ }; } +async function waitForInitialConsume(runtimeRequire) { + for (let index = 0; index < 10; index++) { + if (typeof runtimeRequire.m.consume === 'function') return; + await Promise.resolve(); + } + expect(runtimeRequire.m.consume).toBeTypeOf('function'); +} + describe('module federation default runtime share scopes', () => { it('preserves scalar custom-scope container initialization', () => { const { initContainerCalls, instance, runtimeRequire, shareScopeMap } = @@ -280,7 +288,7 @@ describe('module federation default runtime share scopes', () => { expect(calls[0][2].shareScopeKeys).toEqual(['primary', 'secondary']); }); - it('initializes ordered scopes before installing initial consumes', () => { + it('initializes ordered scopes before installing initial consumes', async () => { const calls = []; const enhancedContainer = { init(...args) { @@ -299,7 +307,31 @@ describe('module federation default runtime share scopes', () => { expect(calls).toHaveLength(1); expect(calls[0][0]).toBe(shareScopeMap.primary); expect(calls[0][2].shareScopeKeys).toEqual(['primary', 'secondary']); - expect(runtimeRequire.m.consume).toBeTypeOf('function'); + await waitForInitialConsume(runtimeRequire); + }); + + it('waits for ordered scopes before installing initial consumes', async () => { + let resolveInit; + const initPromise = new Promise(resolve => { + resolveInit = resolve; + }); + const enhancedContainer = { + init() { + return initPromise; + }, + }; + const { runtimeRequire } = createRuntime({ + external: enhancedContainer, + consumeData: { + shareKey: 'react', + shareScope: ['primary', 'secondary'], + }, + initialConsumes: ['consume'], + }); + + expect(runtimeRequire.m.consume).toBeUndefined(); + resolveInit(); + await waitForInitialConsume(runtimeRequire); }); it('initializes a frozen legacy container without proxying its exports', async () => { From 4aad05beae816579dbd649fbe9af3d99443ee473 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 29 Jul 2026 01:55:49 +0000 Subject: [PATCH 08/27] fix(mf): gate ordered eager startup --- packages/rspack/src/container/ModuleFederationPlugin.ts | 9 ++++++++- .../rspack/src/runtime/moduleFederationDefaultRuntime.js | 6 +++++- tests/rspack-test/ModuleFederationRuntime.test.js | 7 +++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/rspack/src/container/ModuleFederationPlugin.ts b/packages/rspack/src/container/ModuleFederationPlugin.ts index 7d93e2efd9a3..b13aede227d7 100644 --- a/packages/rspack/src/container/ModuleFederationPlugin.ts +++ b/packages/rspack/src/container/ModuleFederationPlugin.ts @@ -111,7 +111,14 @@ export class ModuleFederationPlugin { this._treeShakingSharedPlugin.apply(compiler); } - const asyncStartup = this._options.experiments?.asyncStartup ?? false; + const hasOrderedEagerConsume = getSharedOptions(this._options).some( + ([, config]) => + config.eager === true && + Array.isArray(config.shareScope ?? this._options.shareScope), + ); + const asyncStartup = + this._options.experiments?.asyncStartup === true || + hasOrderedEagerConsume; const runtimeExperiments: ModuleFederationRuntimeExperimentsOptions = { asyncStartup, }; diff --git a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js index 8ec005948721..460305dcc11b 100644 --- a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js +++ b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js @@ -407,6 +407,8 @@ export default function () { return initPromises; }; override(runtimeRequire.f, 'consumes', (chunkId, promises) => { + const initialConsumesInit = runtimeRequire.federation.initialConsumesInit; + if (initialConsumesInit?.then) promises.push(initialConsumesInit); const consume = (targetPromises) => runtimeRequire.federation.bundlerRuntime.consumes({ chunkId, @@ -532,7 +534,9 @@ export default function () { if (initPromises.length === 0) { installInitialConsumes(); } else { - Promise.all(initPromises).then(installInitialConsumes); + runtimeRequire.federation.initialConsumesInit = Promise.all( + initPromises, + ).then(installInitialConsumes); } } } diff --git a/tests/rspack-test/ModuleFederationRuntime.test.js b/tests/rspack-test/ModuleFederationRuntime.test.js index 5f3ed9ef2ea8..f114c05314ea 100644 --- a/tests/rspack-test/ModuleFederationRuntime.test.js +++ b/tests/rspack-test/ModuleFederationRuntime.test.js @@ -58,6 +58,7 @@ function createRuntime({ }, }); if (consumeData) { + runtimeRequire.f.consumes = () => {}; runtimeRequire.consumesLoadingData = { chunkMapping: {}, initialConsumes, @@ -99,6 +100,7 @@ function createRuntime({ }; const localBundlerRuntime = { ...bundlerRuntime, + consumes: () => {}, init: () => instance, getSharedFallbackGetter: ({ shareKey }) => shareKey, initContainerEntry: options => { @@ -330,6 +332,11 @@ describe('module federation default runtime share scopes', () => { }); expect(runtimeRequire.m.consume).toBeUndefined(); + const startupPromises = []; + runtimeRequire.f.consumes('startup', startupPromises); + expect(startupPromises).toContain( + runtimeRequire.federation.initialConsumesInit, + ); resolveInit(); await waitForInitialConsume(runtimeRequire); }); From 1656d1bec8cffe236bfa560789f1e94d3c8c3db8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 29 Jul 2026 02:05:05 +0000 Subject: [PATCH 09/27] fix(mf): gate all ordered consume startups --- packages/rspack/src/container/ModuleFederationPlugin.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/rspack/src/container/ModuleFederationPlugin.ts b/packages/rspack/src/container/ModuleFederationPlugin.ts index b13aede227d7..3e2d815e3a9b 100644 --- a/packages/rspack/src/container/ModuleFederationPlugin.ts +++ b/packages/rspack/src/container/ModuleFederationPlugin.ts @@ -111,14 +111,12 @@ export class ModuleFederationPlugin { this._treeShakingSharedPlugin.apply(compiler); } - const hasOrderedEagerConsume = getSharedOptions(this._options).some( + const hasOrderedConsume = getSharedOptions(this._options).some( ([, config]) => - config.eager === true && Array.isArray(config.shareScope ?? this._options.shareScope), ); const asyncStartup = - this._options.experiments?.asyncStartup === true || - hasOrderedEagerConsume; + this._options.experiments?.asyncStartup === true || hasOrderedConsume; const runtimeExperiments: ModuleFederationRuntimeExperimentsOptions = { asyncStartup, }; From e2d534cdd13d9104121faa0ce7a8d671d8c96c8d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 8 Sep 2026 00:12:48 +0000 Subject: [PATCH 10/27] fix(mf): keep scalar initial consumes synchronous The bundler runtime's initializeSharing always returns a promise, so pre-initializing every initial consume scope deferred installInitialConsumes even for scalar scopes, and a synchronous entry ran before its eager consume factories existed (serialCases/container/eager-shared, cacheCases/mf/issue-9150). Only ordered (array) scopes need initializing up front; they also enable async startup, which awaits initialConsumesInit. Scalar scopes keep the legacy contract and are initialized lazily by the bundler runtime. --- .../runtime/moduleFederationDefaultRuntime.js | 15 ++++++++---- .../ModuleFederationRuntime.test.js | 24 +++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js index 460305dcc11b..bc40496900ad 100644 --- a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js +++ b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js @@ -388,17 +388,22 @@ export default function () { webpackRequire: runtimeRequire, }), ); + // Only ordered (array) scopes are initialized before their consumes + // resolve: the bundler runtime cannot initialize them lazily, and they + // enable async startup, which awaits `initialConsumesInit`. Scalar scopes + // keep the legacy contract (factories install synchronously, the scope is + // initialized lazily), because `initializeSharing` always returns a + // promise and a synchronous entry would otherwise run before its eager + // consumes exist. const initializeConsumeShareScopes = (moduleIds) => { if (!moduleIds?.length) return []; const initPromises = []; const initializedScopes = new Set(); for (const moduleId of moduleIds) { const shareScope = - consumesLoadingModuleToConsumeDataMapping[moduleId]?.shareScope || - 'default'; - const scopeKey = JSON.stringify( - Array.isArray(shareScope) ? shareScope : [shareScope], - ); + consumesLoadingModuleToConsumeDataMapping[moduleId]?.shareScope; + if (!Array.isArray(shareScope)) continue; + const scopeKey = JSON.stringify(shareScope); if (initializedScopes.has(scopeKey)) continue; initializedScopes.add(scopeKey); const initialized = runtimeRequire.I(shareScope, []); diff --git a/tests/rspack-test/ModuleFederationRuntime.test.js b/tests/rspack-test/ModuleFederationRuntime.test.js index f114c05314ea..5728ee63de1d 100644 --- a/tests/rspack-test/ModuleFederationRuntime.test.js +++ b/tests/rspack-test/ModuleFederationRuntime.test.js @@ -290,6 +290,30 @@ describe('module federation default runtime share scopes', () => { expect(calls[0][2].shareScopeKeys).toEqual(['primary', 'secondary']); }); + it('installs scalar initial consumes synchronously', () => { + const calls = []; + const enhancedContainer = { + init(...args) { + calls.push(args); + }, + }; + const { runtimeRequire } = createRuntime({ + external: enhancedContainer, + remoteShareScope: 'default', + consumeData: { + shareKey: 'react', + shareScope: 'default', + }, + initialConsumes: ['consume'], + }); + + // Scalar scopes keep the legacy contract: eager factories are available + // to a synchronous entry, and the scope is initialized lazily. + expect(runtimeRequire.m.consume).toBeTypeOf('function'); + expect(runtimeRequire.federation.initialConsumesInit).toBeUndefined(); + expect(calls).toHaveLength(0); + }); + it('initializes ordered scopes before installing initial consumes', async () => { const calls = []; const enhancedContainer = { From a8ea59e41ecb08c1905e20ba9f1d3cbeadd78aa9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 8 Sep 2026 00:32:16 +0000 Subject: [PATCH 11/27] fix(mf): keep the container primary scope bound when adding scopes initContainer rewrote the host's scalar shareScopeKeys into an array to declare additional ordered scopes. runtime-tools' initContainerEntry handles an array by registering the host's scope names on the container and never binds the container's primary scope to the host's scope object, so a container whose primary scope is named differently from the host's lost its alias as soon as an additional scope was declared. Pass the host options through untouched (scalar contract) and map/initialize additional scopes separately. Tested against the real initContainerEntry from runtime-tools 2.9.0. --- .../runtime/moduleFederationDefaultRuntime.js | 60 ++++++------ .../ModuleFederationRuntime.test.js | 94 +++++++++++++++---- 2 files changed, 108 insertions(+), 46 deletions(-) diff --git a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js index bc40496900ad..80e5b0225a9c 100644 --- a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js +++ b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js @@ -452,42 +452,44 @@ export default function () { runtimeRequire, 'initContainer', (shareScope, initScope, remoteEntryInitOptions) => { - let options = remoteEntryInitOptions; - const additionalScopes = []; - if ( - additionalContainerInitScopes?.length && - options?.shareScopeMap && - !Array.isArray(options.shareScopeKeys) - ) { - const primaryScope = options.shareScopeKeys || 'default'; - const shareScopeKeys = [primaryScope]; - const containerScopes = Array.isArray(containerShareScope) - ? containerShareScope - : [containerShareScope || 'default']; - for (const scope of additionalContainerInitScopes) { - if (scope === primaryScope) continue; - shareScopeKeys.push(scope); - if (!containerScopes.includes(scope)) additionalScopes.push(scope); - } - const descriptors = Object.getOwnPropertyDescriptors(options); - descriptors.shareScopeKeys = { - configurable: true, - enumerable: - Object.getOwnPropertyDescriptor(options, 'shareScopeKeys') - ?.enumerable ?? true, - value: shareScopeKeys, - writable: true, - }; - options = Object.create(Object.getPrototypeOf(options), descriptors); - } + // The host's options are passed through untouched so the bundler + // runtime keeps the scalar contract: the container's primary scope is + // bound to the host's scope object, whatever either side names it. + // Rewriting `shareScopeKeys` into an array made the runtime register + // scopes under the host's names instead, losing that binding. const result = runtimeRequire.federation.bundlerRuntime.initContainerEntry({ shareScope, initScope, - remoteEntryInitOptions: options, + remoteEntryInitOptions, shareScopeKey: containerShareScope, webpackRequire: runtimeRequire, }); + const hostShareScopeMap = remoteEntryInitOptions?.shareScopeMap; + if ( + !additionalContainerInitScopes?.length || + !hostShareScopeMap || + Array.isArray(remoteEntryInitOptions.shareScopeKeys) + ) { + return result; + } + // Additional (ordered) scopes are mapped and initialized separately + // from the primary binding. + const primaryScope = remoteEntryInitOptions.shareScopeKeys || 'default'; + const containerScopes = Array.isArray(containerShareScope) + ? containerShareScope + : [containerShareScope || 'default']; + const additionalScopes = []; + for (const scope of additionalContainerInitScopes) { + if (scope === primaryScope) continue; + if (!hostShareScopeMap[scope]) hostShareScopeMap[scope] = {}; + runtimeRequire.federation.instance.initShareScopeMap( + scope, + hostShareScopeMap[scope], + { hostShareScopeMap }, + ); + if (!containerScopes.includes(scope)) additionalScopes.push(scope); + } if (additionalScopes.length === 0) return result; const initializeAdditionalScopes = () => Promise.all( diff --git a/tests/rspack-test/ModuleFederationRuntime.test.js b/tests/rspack-test/ModuleFederationRuntime.test.js index 5728ee63de1d..1f436e46ee83 100644 --- a/tests/rspack-test/ModuleFederationRuntime.test.js +++ b/tests/rspack-test/ModuleFederationRuntime.test.js @@ -27,12 +27,17 @@ function createRuntime({ initialConsumes, additionalInitScopes = [], scopeToSharingDataMapping = {}, + // Use the real bundler-runtime initContainerEntry instead of recording + // its arguments; the runtime under test then gets its own scope map. + realInitContainerEntry = false, } = {}) { - const shareScopeMap = { - primary: { tag: 'primary' }, - secondary: { tag: 'secondary' }, - 'host-custom': { tag: 'host-custom' }, - }; + const shareScopeMap = realInitContainerEntry + ? {} + : { + primary: { tag: 'primary' }, + secondary: { tag: 'secondary' }, + 'host-custom': { tag: 'host-custom' }, + }; const runtimeRequire = () => external; Object.assign(runtimeRequire, { S: shareScopeMap, @@ -96,6 +101,10 @@ function createRuntime({ initializeSharing(shareScope, options) { return this.sharedHandler.initializeSharing(shareScope, options); }, + initOptions() {}, + initShareScopeMap(key, scope) { + this.shareScopeMap[key] = scope; + }, registerShared() {}, }; const localBundlerRuntime = { @@ -103,9 +112,11 @@ function createRuntime({ consumes: () => {}, init: () => instance, getSharedFallbackGetter: ({ shareKey }) => shareKey, - initContainerEntry: options => { - initContainerCalls.push(options); - }, + initContainerEntry: realInitContainerEntry + ? bundlerRuntime.initContainerEntry + : options => { + initContainerCalls.push(options); + }, }; const importedBundlerRuntime = { ...localBundlerRuntime, @@ -201,17 +212,66 @@ describe('module federation default runtime share scopes', () => { remoteEntryInitOptions, ); + // The host's options reach the bundler runtime untouched (scalar + // primary binding); additional scopes are mapped separately. expect(initContainerCalls).toHaveLength(1); - expect( - initContainerCalls[0].remoteEntryInitOptions.shareScopeKeys, - ).toEqual(['host-custom', 'secondary']); + expect(initContainerCalls[0].remoteEntryInitOptions).toBe( + remoteEntryInitOptions, + ); expect(remoteEntryInitOptions.shareScopeKeys).toBe('host-custom'); - expect( - Object.getOwnPropertyDescriptor( - initContainerCalls[0].remoteEntryInitOptions, - 'shareScopeMap', - ).enumerable, - ).toBe(false); + expect(shareScopeMap.secondary).toBe(remoteEntryInitOptions.shareScopeMap.secondary); + }); + + describe('with the real initContainerEntry', () => { + const hostShareScopeMap = () => ({ + 'host-custom': { tag: 'host-custom' }, + secondary: { tag: 'secondary' }, + }); + + it('binds the container primary scope to the host scope object', async () => { + const { runtimeRequire, shareScopeMap: containerScopeMap } = + createRuntime({ + realInitContainerEntry: true, + containerShareScope: 'container-custom', + remoteShareScope: 'host-custom', + }); + const shareScopeMap = hostShareScopeMap(); + + await runtimeRequire.initContainer(shareScopeMap['host-custom'], [], { + shareScopeKeys: 'host-custom', + shareScopeMap, + }); + + expect(containerScopeMap['container-custom']).toBe( + shareScopeMap['host-custom'], + ); + }); + + it('keeps the primary binding when expanding additional scopes', async () => { + const { initializedScopes, runtimeRequire, shareScopeMap: containerScopeMap } = + createRuntime({ + realInitContainerEntry: true, + containerShareScope: 'container-custom', + remoteShareScope: 'host-custom', + additionalInitScopes: ['secondary'], + }); + const shareScopeMap = hostShareScopeMap(); + + await runtimeRequire.initContainer(shareScopeMap['host-custom'], [], { + shareScopeKeys: 'host-custom', + shareScopeMap, + }); + + // primary alias: container name -> host object + expect(containerScopeMap['container-custom']).toBe( + shareScopeMap['host-custom'], + ); + // additional scope shares the host's object and gets initialized + expect(containerScopeMap.secondary).toBe(shareScopeMap.secondary); + expect(initializedScopes).toContain('secondary'); + // the scalar path never registers the host's name on the container + expect(containerScopeMap['host-custom']).toBeUndefined(); + }); }); it('does not post-initialize scopes owned by the container', async () => { From 0a411d421dcb9845591f7b3111b8446c85f1ed3f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 8 Sep 2026 01:14:13 +0000 Subject: [PATCH 12/27] fix(mf): derive async startup from the runtime's actual initial consumes Async startup was selected only from the high-level shared option, so an ordered (array-scoped) initial consume contributed by a separately installed enhanced ConsumeSharedPlugin kept synchronous startup and its eager factory did not exist when the entry ran. Resolve the startup mode per runtime chunk in EmbedFederationRuntimePlugin from the actual initial ConsumeSharedModules (any ShareScope::Multiple => async), and pass the resolved flag to the runtime module. Scalar-only initial consumes and ordered consumes reachable only through async chunks keep synchronous startup. Also read ensure-chunk handlers through ENSURE_CHUNK_HANDLERS in the async startup template; it hard-coded REQUIRE.f, which does not exist in rspack runtime mode. --- .../embed_federation_runtime_async.ejs | 2 +- .../embed_federation_runtime_module.rs | 1 + .../embed_federation_runtime_plugin.rs | 19 +++++-- .../module_federation_runtime_plugin.rs | 42 ++++++++++++++- .../ordered-consume-startup/async-only.js | 3 ++ .../ordered-consume-startup/initial.js | 7 +++ .../ordered-consume-startup/mixed.js | 7 +++ .../node_modules/lib/index.js | 1 + .../node_modules/lib/package.json | 3 ++ .../node_modules/scalar-lib/index.js | 1 + .../node_modules/scalar-lib/package.json | 3 ++ .../ordered-consume-startup/rspack.config.js | 54 +++++++++++++++++++ 12 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-startup/async-only.js create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-startup/initial.js create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-startup/mixed.js create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-startup/node_modules/lib/index.js create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-startup/node_modules/lib/package.json create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-startup/node_modules/scalar-lib/index.js create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-startup/node_modules/scalar-lib/package.json create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-startup/rspack.config.js diff --git a/crates/rspack_plugin_mf/src/container/embed_federation_runtime_async.ejs b/crates/rspack_plugin_mf/src/container/embed_federation_runtime_async.ejs index 066d7f39d291..8a9e13a2fb6f 100644 --- a/crates/rspack_plugin_mf/src/container/embed_federation_runtime_async.ejs +++ b/crates/rspack_plugin_mf/src/container/embed_federation_runtime_async.ejs @@ -18,7 +18,7 @@ hasRun = true; var base = mfStartupBase && mfStartupBase(); var promises = base && base.then ? [base] : []; - var handlers = <%- REQUIRE %>.f; + var handlers = <%- ENSURE_CHUNK_HANDLERS %>; if (handlers) { var startupChunkIds = <%- _entry_chunk_ids %>; for (var i = 0; i < startupChunkIds.length; i++) { diff --git a/crates/rspack_plugin_mf/src/container/embed_federation_runtime_module.rs b/crates/rspack_plugin_mf/src/container/embed_federation_runtime_module.rs index cf0b71d319a6..33961a2a4071 100644 --- a/crates/rspack_plugin_mf/src/container/embed_federation_runtime_module.rs +++ b/crates/rspack_plugin_mf/src/container/embed_federation_runtime_module.rs @@ -73,6 +73,7 @@ impl RuntimeModule for EmbedFederationRuntimeModule { _compilation: &Compilation, ) -> rspack_core::RuntimeModuleRuntimeRequirements { let mut define = RuntimeGlobals::STARTUP; + // `async_startup` is resolved per runtime chunk by EmbedFederationRuntimePlugin. if self.options.experiments.async_startup { define.insert(RuntimeGlobals::STARTUP_ENTRYPOINT); } diff --git a/crates/rspack_plugin_mf/src/container/embed_federation_runtime_plugin.rs b/crates/rspack_plugin_mf/src/container/embed_federation_runtime_plugin.rs index bcd14563c489..343cb41afc55 100644 --- a/crates/rspack_plugin_mf/src/container/embed_federation_runtime_plugin.rs +++ b/crates/rspack_plugin_mf/src/container/embed_federation_runtime_plugin.rs @@ -88,7 +88,10 @@ async fn additional_chunk_runtime_requirements_tree( // Add STARTUP (sync) so the runtime wrapper can always hook __rspack_require.x, // and STARTUP_ENTRYPOINT (async) when async startup is enabled. runtime_requirements.insert(RuntimeGlobals::STARTUP); - if self.experiments.async_startup { + if self + .experiments + .needs_async_startup(compilation, chunk_ukey) + { runtime_requirements.insert(RuntimeGlobals::STARTUP_ENTRYPOINT); runtime_requirements.insert(RuntimeGlobals::ENSURE_CHUNK_HANDLERS); } @@ -129,9 +132,16 @@ async fn runtime_requirement_in_tree( .copied() .collect::>(); + // Resolve the startup mode here, where the chunk graph is available; the + // runtime module's `runtime_requirements` runs before it is attached to a + // chunk. let emro = EmbedFederationRuntimeModuleOptions { collected_dependency_ids: collected_ids_snapshot, - experiments: self.experiments.clone(), + experiments: ModuleFederationRuntimeExperimentsOptions { + async_startup: self + .experiments + .needs_async_startup(compilation, chunk_ukey), + }, }; // Inject EmbedFederationRuntimeModule @@ -225,7 +235,10 @@ async fn render_startup( // Entry chunks delegating to runtime need explicit startup calls (sync only) if !has_runtime && has_entry_modules { - if self.experiments.async_startup { + if self + .experiments + .needs_async_startup(compilation, chunk_ukey) + { return Ok(()); } let mut startup_with_call = ConcatSource::default(); diff --git a/crates/rspack_plugin_mf/src/container/module_federation_runtime_plugin.rs b/crates/rspack_plugin_mf/src/container/module_federation_runtime_plugin.rs index 7ece935c9fab..a47720761321 100644 --- a/crates/rspack_plugin_mf/src/container/module_federation_runtime_plugin.rs +++ b/crates/rspack_plugin_mf/src/container/module_federation_runtime_plugin.rs @@ -8,7 +8,7 @@ use rspack_core::{ AsyncModulesArtifact, BoxDependency, ChunkUkey, Compilation, CompilationAdditionalTreeRuntimeRequirements, CompilationFinishModules, CompilationParams, CompilerCompilation, CompilerFinishMake, DependencyType, EntryOptions, ExportsInfoArtifact, - Plugin, RuntimeGlobals, RuntimeModule, SideEffectsStateArtifact, + Plugin, RuntimeGlobals, RuntimeModule, SideEffectsStateArtifact, SourceType, }; use rspack_error::Result; use rspack_hook::{plugin, plugin_hook}; @@ -22,6 +22,7 @@ use super::{ federation_runtime_dependency::FederationRuntimeDependency, hoist_container_references_plugin::HoistContainerReferencesPlugin, }; +use crate::{ShareScope, sharing::consume_shared_module::ConsumeSharedModule}; #[derive(Debug, Default, Deserialize, Clone)] pub struct ModuleFederationRuntimePluginOptions { @@ -37,6 +38,45 @@ pub struct ModuleFederationRuntimeExperimentsOptions { pub async_startup: bool, } +impl ModuleFederationRuntimeExperimentsOptions { + /// Whether the startup of `chunk_ukey`'s runtime must be asynchronous. + /// + /// Ordered (array) share scopes are initialized before their consumes are + /// installed, so a runtime whose initial chunks contain such a consume must + /// await that initialization. This is derived from the actual initial + /// `ConsumeSharedModule`s rather than only from the high-level `shared` + /// option, so consumes contributed by a separately installed enhanced + /// `ConsumeSharedPlugin` are covered too. Scalar scopes keep synchronous + /// startup. + pub fn needs_async_startup(&self, compilation: &Compilation, chunk_ukey: &ChunkUkey) -> bool { + if self.async_startup { + return true; + } + let module_graph = compilation.get_module_graph(); + let artifact = &compilation.build_chunk_graph_artifact; + artifact + .chunk_by_ukey + .expect_get(chunk_ukey) + .get_all_initial_chunks(&artifact.chunk_group_by_ukey) + .into_iter() + .flat_map(|chunk| { + artifact + .chunk_graph + .get_chunk_modules_identifier_by_source_type( + &chunk, + SourceType::ConsumeShared, + module_graph, + ) + }) + .any(|module_identifier| { + module_graph + .module_by_identifier(&module_identifier) + .and_then(|module| module.as_any().downcast_ref::()) + .is_some_and(|module| matches!(module.share_scope(), ShareScope::Multiple(_))) + }) + } +} + #[plugin] #[derive(Debug)] pub struct ModuleFederationRuntimePlugin { diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/async-only.js b/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/async-only.js new file mode 100644 index 000000000000..9fc9cc36eab6 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/async-only.js @@ -0,0 +1,3 @@ +it("should resolve an ordered consume that is only reachable through an async chunk", async () => { + expect(await import("lib")).toEqual(expect.objectContaining({ default: "lib" })); +}); diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/initial.js b/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/initial.js new file mode 100644 index 000000000000..8b57f8a51a73 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/initial.js @@ -0,0 +1,7 @@ +// A synchronous entry with an eager ordered consume: the factory must exist +// when this module runs, so startup has to await scope initialization. +const lib = require("lib"); + +it("should await ordered scope initialization before a synchronous entry", () => { + expect(lib).toBe("lib"); +}); diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/mixed.js b/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/mixed.js new file mode 100644 index 000000000000..59c527db75af --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/mixed.js @@ -0,0 +1,7 @@ +const scalarLib = require("scalar-lib"); +const lib = require("lib"); + +it("should install scalar and ordered eager consumes before a synchronous entry", () => { + expect(scalarLib).toBe("scalar-lib"); + expect(lib).toBe("lib"); +}); diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/node_modules/lib/index.js b/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/node_modules/lib/index.js new file mode 100644 index 000000000000..1aaff98de2ae --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/node_modules/lib/index.js @@ -0,0 +1 @@ +module.exports = "lib"; diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/node_modules/lib/package.json b/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/node_modules/lib/package.json new file mode 100644 index 000000000000..1587a669681c --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/node_modules/lib/package.json @@ -0,0 +1,3 @@ +{ + "version": "1.0.0" +} diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/node_modules/scalar-lib/index.js b/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/node_modules/scalar-lib/index.js new file mode 100644 index 000000000000..22e3346eb88c --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/node_modules/scalar-lib/index.js @@ -0,0 +1 @@ +module.exports = "scalar-lib"; diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/node_modules/scalar-lib/package.json b/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/node_modules/scalar-lib/package.json new file mode 100644 index 000000000000..1587a669681c --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/node_modules/scalar-lib/package.json @@ -0,0 +1,3 @@ +{ + "version": "1.0.0" +} diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/rspack.config.js b/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/rspack.config.js new file mode 100644 index 000000000000..6aee561cb9ee --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-startup/rspack.config.js @@ -0,0 +1,54 @@ +const { ModuleFederationPlugin } = require('@rspack/core').container; +const { ConsumeSharedPlugin } = require('@rspack/core').sharing; + +// Async startup must be derived from the actual initial consumes of the +// runtime, not only from the high-level `shared` option: these consumes are +// contributed by a separately installed enhanced ConsumeSharedPlugin. +const consume = (name, shareScope, eager) => ({ + [name]: { shareScope, eager, requiredVersion: false }, +}); + +/** @type {import("@rspack/core").Configuration[]} */ +module.exports = [ + // ordered initial (eager) consume: startup awaits scope initialization + { + entry: './initial.js', + output: { uniqueName: 'ordered-consume-startup-initial' }, + plugins: [ + new ModuleFederationPlugin({ name: 'ordered_consume_startup_initial' }), + new ConsumeSharedPlugin({ + enhanced: true, + consumes: consume('lib', ['primary', 'default'], true), + }), + ], + }, + // ordered consume only behind an async chunk: startup stays synchronous + { + entry: './async-only.js', + output: { uniqueName: 'ordered-consume-startup-async-only' }, + plugins: [ + new ModuleFederationPlugin({ + name: 'ordered_consume_startup_async_only', + }), + new ConsumeSharedPlugin({ + enhanced: true, + consumes: consume('lib', ['primary', 'default'], false), + }), + ], + }, + // mixed scalar + ordered initial consumes + { + entry: './mixed.js', + output: { uniqueName: 'ordered-consume-startup-mixed' }, + plugins: [ + new ModuleFederationPlugin({ name: 'ordered_consume_startup_mixed' }), + new ConsumeSharedPlugin({ + enhanced: true, + consumes: { + ...consume('scalar-lib', 'default', true), + ...consume('lib', ['primary', 'default'], true), + }, + }), + ], + }, +]; From 451b6f323724856d4101a5c1e54db9da26a10e2f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 8 Sep 2026 01:57:30 +0000 Subject: [PATCH 13/27] fix(mf): address startup and scope-binding review findings - Forward only the explicit asyncStartup experiment from ModuleFederationPlugin; automatic async startup for ordered scopes is derived natively from the runtime's initial consumes, so unused, provide-only or async-chunk-only ordered shares keep synchronous startup instead of being forced async by the high-level predicate. - Mark container entries async in finish_modules whenever an ordered consume module exists, not only when the experiment is set, so a module-library container whose startup becomes a promise is awaited. - Skip container-owned scopes before remapping additional scopes in initContainer; remapping them from the host map replaced the primary binding initContainerEntry had just established. Covered against the real initContainerEntry. --- .../module_federation_runtime_plugin.rs | 18 +++++++++++-- .../src/container/ModuleFederationPlugin.ts | 12 ++++----- .../runtime/moduleFederationDefaultRuntime.js | 9 +++++-- .../ModuleFederationRuntime.test.js | 25 +++++++++++++++++++ 4 files changed, 53 insertions(+), 11 deletions(-) diff --git a/crates/rspack_plugin_mf/src/container/module_federation_runtime_plugin.rs b/crates/rspack_plugin_mf/src/container/module_federation_runtime_plugin.rs index a47720761321..ac4af43e3496 100644 --- a/crates/rspack_plugin_mf/src/container/module_federation_runtime_plugin.rs +++ b/crates/rspack_plugin_mf/src/container/module_federation_runtime_plugin.rs @@ -154,11 +154,25 @@ async fn finish_modules( _exports_info_artifact: &mut ExportsInfoArtifact, _side_effects_state_artifact: &mut SideEffectsStateArtifact, ) -> Result<()> { - if !self.options.experiments.async_startup { + let module_graph = compilation.get_module_graph(); + // The chunk graph does not exist yet, so "initial" is unknown here. Any + // ordered consume may make startup asynchronous, and a container entry that + // is not marked async while startup returns a promise breaks module-library + // output. Over-marking only adds an await of a non-promise. + // ponytail: this marks container entries async for ordered consumes that end + // up async-chunk-only; refine once async-module marking can run after the + // chunk graph is built. + let has_ordered_consume = module_graph.modules().any(|(_, module)| { + module + .as_ref() + .as_any() + .downcast_ref::() + .is_some_and(|module| matches!(module.share_scope(), ShareScope::Multiple(_))) + }); + if !self.options.experiments.async_startup && !has_ordered_consume { return Ok(()); } - let module_graph = compilation.get_module_graph(); for (module_identifier, module) in module_graph.modules() { if module .as_ref() diff --git a/packages/rspack/src/container/ModuleFederationPlugin.ts b/packages/rspack/src/container/ModuleFederationPlugin.ts index 3e2d815e3a9b..57cc0827405c 100644 --- a/packages/rspack/src/container/ModuleFederationPlugin.ts +++ b/packages/rspack/src/container/ModuleFederationPlugin.ts @@ -111,14 +111,12 @@ export class ModuleFederationPlugin { this._treeShakingSharedPlugin.apply(compiler); } - const hasOrderedConsume = getSharedOptions(this._options).some( - ([, config]) => - Array.isArray(config.shareScope ?? this._options.shareScope), - ); - const asyncStartup = - this._options.experiments?.asyncStartup === true || hasOrderedConsume; + // Only the explicit experiment is forwarded. Automatic async startup for + // ordered (array) share scopes is derived natively from the runtime's + // actual initial consumes, so unused, provide-only, or async-chunk-only + // ordered shares keep synchronous startup. const runtimeExperiments: ModuleFederationRuntimeExperimentsOptions = { - asyncStartup, + asyncStartup: this._options.experiments?.asyncStartup === true, }; let runtimePluginApplied = false; compiler.hooks.beforeRun.tap( diff --git a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js index 80e5b0225a9c..84c0f0e62d10 100644 --- a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js +++ b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js @@ -481,14 +481,19 @@ export default function () { : [containerShareScope || 'default']; const additionalScopes = []; for (const scope of additionalContainerInitScopes) { - if (scope === primaryScope) continue; + // Scopes the container owns were already bound by initContainerEntry + // (its own scope to the host's primary object); remapping them from + // the host map would replace that binding. + if (scope === primaryScope || containerScopes.includes(scope)) { + continue; + } if (!hostShareScopeMap[scope]) hostShareScopeMap[scope] = {}; runtimeRequire.federation.instance.initShareScopeMap( scope, hostShareScopeMap[scope], { hostShareScopeMap }, ); - if (!containerScopes.includes(scope)) additionalScopes.push(scope); + additionalScopes.push(scope); } if (additionalScopes.length === 0) return result; const initializeAdditionalScopes = () => diff --git a/tests/rspack-test/ModuleFederationRuntime.test.js b/tests/rspack-test/ModuleFederationRuntime.test.js index 1f436e46ee83..cc31de6cef60 100644 --- a/tests/rspack-test/ModuleFederationRuntime.test.js +++ b/tests/rspack-test/ModuleFederationRuntime.test.js @@ -272,6 +272,31 @@ describe('module federation default runtime share scopes', () => { // the scalar path never registers the host's name on the container expect(containerScopeMap['host-custom']).toBeUndefined(); }); + + it('does not remap a container-owned scope listed as additional', async () => { + // A layered provider makes ShareRuntimeModule list the container's own + // scope in additionalInitScopes; the host names that scope differently. + const { initializedScopes, runtimeRequire, shareScopeMap: containerScopeMap } = + createRuntime({ + realInitContainerEntry: true, + containerShareScope: 'container-custom', + remoteShareScope: 'host-custom', + additionalInitScopes: ['container-custom', 'secondary'], + }); + const shareScopeMap = hostShareScopeMap(); + + await runtimeRequire.initContainer(shareScopeMap['host-custom'], [], { + shareScopeKeys: 'host-custom', + shareScopeMap, + }); + + expect(containerScopeMap['container-custom']).toBe( + shareScopeMap['host-custom'], + ); + expect(shareScopeMap['container-custom']).toBeUndefined(); + expect(containerScopeMap.secondary).toBe(shareScopeMap.secondary); + expect(initializedScopes.filter(scope => scope === 'secondary')).toHaveLength(1); + }); }); it('does not post-initialize scopes owned by the container', async () => { From 7d90b41380bd59cabddd21362f37a9dffa7a6fe8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 8 Sep 2026 04:19:15 +0000 Subject: [PATCH 14/27] fix(mf): decide startup per runtime and initialize scopes per consume path - needs_async_startup resolves the owning runtime chunk(s) of the queried chunk (entrypoint groups' runtime chunks) and inspects their initial chunks, so an entry that delegates to a shared runtime (runtimeChunk: 'single') takes the same startup mode as that runtime instead of getting a synchronous startup call prepended while the runtime defers its factories. Fixture: sibling entries sharing one runtime in both load orders, plus an independent-runtime control. - Share-scope initialization before consuming: the chunk path initializes every scope and waits, so a remote registered through I() (module/ promise externals initialize asynchronously) contributes its shares before a consume resolves to a local fallback (layers-full hosts). The initial path only handles ordered scopes: initializing scalar scopes there starts loading eager shares asynchronously and breaks a synchronous eager consume, and installation must stay synchronous. - initContainer: a host initializes a container once per shared scope. Initializing one of the container's additional scopes by name binds that scope only (array form, host name); applying the scalar contract there aliased the container's primary pool to a different host pool and mixed scopes. The primary init keeps the scalar contract and maps the remaining additional scopes. Covered against the real initContainerEntry. --- .../module_federation_runtime_plugin.rs | 37 ++++- .../runtime/moduleFederationDefaultRuntime.js | 128 +++++++++++------- .../ModuleFederationRuntime.test.js | 76 ++++++++++- .../node_modules/lib/index.js | 1 + .../node_modules/lib/package.json | 3 + .../node_modules/scalar-lib/index.js | 1 + .../node_modules/scalar-lib/package.json | 3 + .../ordered-consume-shared-runtime/ordered.js | 5 + .../rspack.config.js | 50 +++++++ .../ordered-consume-shared-runtime/scalar.js | 24 ++++ .../test.config.js | 23 ++++ 11 files changed, 294 insertions(+), 57 deletions(-) create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/node_modules/lib/index.js create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/node_modules/lib/package.json create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/node_modules/scalar-lib/index.js create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/node_modules/scalar-lib/package.json create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/ordered.js create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/rspack.config.js create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/scalar.js create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/test.config.js diff --git a/crates/rspack_plugin_mf/src/container/module_federation_runtime_plugin.rs b/crates/rspack_plugin_mf/src/container/module_federation_runtime_plugin.rs index ac4af43e3496..8d946ec3d1fc 100644 --- a/crates/rspack_plugin_mf/src/container/module_federation_runtime_plugin.rs +++ b/crates/rspack_plugin_mf/src/container/module_federation_runtime_plugin.rs @@ -39,7 +39,7 @@ pub struct ModuleFederationRuntimeExperimentsOptions { } impl ModuleFederationRuntimeExperimentsOptions { - /// Whether the startup of `chunk_ukey`'s runtime must be asynchronous. + /// Whether the startup of `chunk_ukey` must be asynchronous. /// /// Ordered (array) share scopes are initialized before their consumes are /// installed, so a runtime whose initial chunks contain such a consume must @@ -48,17 +48,40 @@ impl ModuleFederationRuntimeExperimentsOptions { /// option, so consumes contributed by a separately installed enhanced /// `ConsumeSharedPlugin` are covered too. Scalar scopes keep synchronous /// startup. + /// + /// The decision is owned by the runtime chunk: the runtime installs the + /// initial consumes of every entry it serves in one call, so an entry chunk + /// that delegates to a shared runtime (`runtimeChunk: 'single'`) must take + /// the same mode as that runtime even when its own initial chunks contain + /// no ordered consume. pub fn needs_async_startup(&self, compilation: &Compilation, chunk_ukey: &ChunkUkey) -> bool { if self.async_startup { return true; } - let module_graph = compilation.get_module_graph(); let artifact = &compilation.build_chunk_graph_artifact; - artifact - .chunk_by_ukey - .expect_get(chunk_ukey) - .get_all_initial_chunks(&artifact.chunk_group_by_ukey) - .into_iter() + let chunk = artifact.chunk_by_ukey.expect_get(chunk_ukey); + let mut runtime_chunks: Vec = chunk + .groups() + .iter() + .filter_map(|group_ukey| artifact.chunk_group_by_ukey.get(group_ukey)) + .filter(|group| group.kind.is_entrypoint()) + .map(|group| group.get_runtime_chunk(&artifact.chunk_group_by_ukey)) + .collect(); + if runtime_chunks.is_empty() { + runtime_chunks.push(*chunk_ukey); + } + runtime_chunks.sort_unstable(); + runtime_chunks.dedup(); + + let module_graph = compilation.get_module_graph(); + runtime_chunks + .iter() + .flat_map(|runtime_chunk| { + artifact + .chunk_by_ukey + .expect_get(runtime_chunk) + .get_all_initial_chunks(&artifact.chunk_group_by_ukey) + }) .flat_map(|chunk| { artifact .chunk_graph diff --git a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js index 84c0f0e62d10..1bcc473e9159 100644 --- a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js +++ b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js @@ -388,22 +388,31 @@ export default function () { webpackRequire: runtimeRequire, }), ); - // Only ordered (array) scopes are initialized before their consumes - // resolve: the bundler runtime cannot initialize them lazily, and they - // enable async startup, which awaits `initialConsumesInit`. Scalar scopes - // keep the legacy contract (factories install synchronously, the scope is - // initialized lazily), because `initializeSharing` always returns a - // promise and a synchronous entry would otherwise run before its eager - // consumes exist. - const initializeConsumeShareScopes = (moduleIds) => { + // Initializes the share scopes of the given consume modules and returns + // the promises to wait for before consuming. + // - Chunk path (`includeScalar`): every scope is initialized up front and + // awaited, so a remote registered through `I()` (a `module`/`promise` + // external initializes asynchronously) contributes its shares before a + // consume resolves to a local fallback. Chunk loading is asynchronous + // anyway. + // - Initial path: only ordered (array) scopes, which the bundler runtime + // cannot initialize lazily; they enable async startup, which awaits + // `initialConsumesInit`. Scalar scopes are left to the consume handlers: + // initializing them here would start loading eager shares + // asynchronously and make a synchronous eager consume fail, and + // `initializeSharing` always returns a promise, so installation could + // not stay synchronous either. + const initializeConsumeShareScopes = (moduleIds, includeScalar) => { if (!moduleIds?.length) return []; const initPromises = []; const initializedScopes = new Set(); for (const moduleId of moduleIds) { const shareScope = - consumesLoadingModuleToConsumeDataMapping[moduleId]?.shareScope; - if (!Array.isArray(shareScope)) continue; - const scopeKey = JSON.stringify(shareScope); + consumesLoadingModuleToConsumeDataMapping[moduleId]?.shareScope || + 'default'; + const ordered = Array.isArray(shareScope); + if (!ordered && !includeScalar) continue; + const scopeKey = JSON.stringify(ordered ? shareScope : [shareScope]); if (initializedScopes.has(scopeKey)) continue; initializedScopes.add(scopeKey); const initialized = runtimeRequire.I(shareScope, []); @@ -426,6 +435,7 @@ export default function () { }); const initPromises = initializeConsumeShareScopes( consumesLoadingChunkMapping[chunkId], + true, ); if (initPromises.length === 0) return consume(promises); promises.push( @@ -448,53 +458,76 @@ export default function () { webpackRequire, }); }); + // Returns `options` with `shareScopeKeys` replaced, preserving the + // prototype and property descriptors of the host's object. + const withShareScopeKeys = (options, shareScopeKeys) => { + const descriptors = Object.getOwnPropertyDescriptors(options); + descriptors.shareScopeKeys = { + configurable: true, + enumerable: + Object.getOwnPropertyDescriptor(options, 'shareScopeKeys') + ?.enumerable ?? true, + value: shareScopeKeys, + writable: true, + }; + return Object.create(Object.getPrototypeOf(options), descriptors); + }; override( runtimeRequire, 'initContainer', (shareScope, initScope, remoteEntryInitOptions) => { - // The host's options are passed through untouched so the bundler - // runtime keeps the scalar contract: the container's primary scope is - // bound to the host's scope object, whatever either side names it. - // Rewriting `shareScopeKeys` into an array made the runtime register - // scopes under the host's names instead, losing that binding. + // A host initializes a container once per scope it shares with it. + // - Initializing one of the container's additional scopes (declared by + // ordered or layered shares) by name binds that scope only; the + // bundler runtime's array form maps by host name. Applying the + // scalar contract here would alias the container's primary scope to + // a different host pool and mix scopes. + // - Initializing the primary scope keeps the scalar contract (the + // container's primary scope is bound to the supplied host object, + // whatever either side names it) and maps the remaining additional + // scopes onto the host's pools so their shares register there. + // Scopes the container owns are bound by initContainerEntry itself. + const hostShareScopeMap = remoteEntryInitOptions?.shareScopeMap; + let options = remoteEntryInitOptions; + const additionalScopes = []; + if ( + additionalContainerInitScopes?.length && + hostShareScopeMap && + !Array.isArray(remoteEntryInitOptions.shareScopeKeys) + ) { + const hostScope = remoteEntryInitOptions.shareScopeKeys || 'default'; + const containerScopes = Array.isArray(containerShareScope) + ? containerShareScope + : [containerShareScope || 'default']; + if ( + !containerScopes.includes(hostScope) && + additionalContainerInitScopes.includes(hostScope) + ) { + options = withShareScopeKeys(remoteEntryInitOptions, [hostScope]); + additionalScopes.push(hostScope); + } else { + for (const scope of additionalContainerInitScopes) { + if (scope === hostScope || containerScopes.includes(scope)) { + continue; + } + if (!hostShareScopeMap[scope]) hostShareScopeMap[scope] = {}; + runtimeRequire.federation.instance.initShareScopeMap( + scope, + hostShareScopeMap[scope], + { hostShareScopeMap }, + ); + additionalScopes.push(scope); + } + } + } const result = runtimeRequire.federation.bundlerRuntime.initContainerEntry({ shareScope, initScope, - remoteEntryInitOptions, + remoteEntryInitOptions: options, shareScopeKey: containerShareScope, webpackRequire: runtimeRequire, }); - const hostShareScopeMap = remoteEntryInitOptions?.shareScopeMap; - if ( - !additionalContainerInitScopes?.length || - !hostShareScopeMap || - Array.isArray(remoteEntryInitOptions.shareScopeKeys) - ) { - return result; - } - // Additional (ordered) scopes are mapped and initialized separately - // from the primary binding. - const primaryScope = remoteEntryInitOptions.shareScopeKeys || 'default'; - const containerScopes = Array.isArray(containerShareScope) - ? containerShareScope - : [containerShareScope || 'default']; - const additionalScopes = []; - for (const scope of additionalContainerInitScopes) { - // Scopes the container owns were already bound by initContainerEntry - // (its own scope to the host's primary object); remapping them from - // the host map would replace that binding. - if (scope === primaryScope || containerScopes.includes(scope)) { - continue; - } - if (!hostShareScopeMap[scope]) hostShareScopeMap[scope] = {}; - runtimeRequire.federation.instance.initShareScopeMap( - scope, - hostShareScopeMap[scope], - { hostShareScopeMap }, - ); - additionalScopes.push(scope); - } if (additionalScopes.length === 0) return result; const initializeAdditionalScopes = () => Promise.all( @@ -542,6 +575,7 @@ export default function () { }); const initPromises = initializeConsumeShareScopes( runtimeRequire.consumesLoadingData.initialConsumes, + false, ); if (initPromises.length === 0) { installInitialConsumes(); diff --git a/tests/rspack-test/ModuleFederationRuntime.test.js b/tests/rspack-test/ModuleFederationRuntime.test.js index cc31de6cef60..d7f7b90023c9 100644 --- a/tests/rspack-test/ModuleFederationRuntime.test.js +++ b/tests/rspack-test/ModuleFederationRuntime.test.js @@ -25,6 +25,8 @@ function createRuntime({ sharedFallbackVariants, consumeData, initialConsumes, + chunkMapping = {}, + consumeCalls = [], additionalInitScopes = [], scopeToSharingDataMapping = {}, // Use the real bundler-runtime initContainerEntry instead of recording @@ -65,7 +67,7 @@ function createRuntime({ if (consumeData) { runtimeRequire.f.consumes = () => {}; runtimeRequire.consumesLoadingData = { - chunkMapping: {}, + chunkMapping, initialConsumes, moduleIdToConsumeDataMapping: { consume: consumeData }, }; @@ -109,7 +111,9 @@ function createRuntime({ }; const localBundlerRuntime = { ...bundlerRuntime, - consumes: () => {}, + consumes: options => { + consumeCalls.push(options.chunkId); + }, init: () => instance, getSharedFallbackGetter: ({ shareKey }) => shareKey, initContainerEntry: realInitContainerEntry @@ -273,6 +277,42 @@ describe('module federation default runtime share scopes', () => { expect(containerScopeMap['host-custom']).toBeUndefined(); }); + it('binds an additional scope by name when the host initializes it, leaving the primary pool alone', async () => { + // Hosts initialize a container once per shared scope. Initializing the + // container's additional `layered-components` scope must bind that + // scope only; aliasing the primary `default` pool to it mixes scopes. + const { initializedScopes, runtimeRequire, shareScopeMap: containerScopeMap } = + createRuntime({ + realInitContainerEntry: true, + containerShareScope: 'default', + remoteShareScope: 'default', + additionalInitScopes: ['layered-components'], + }); + const shareScopeMap = { + default: { tag: 'host-default' }, + 'layered-components': { tag: 'host-layered' }, + }; + + await runtimeRequire.initContainer(shareScopeMap['layered-components'], [], { + shareScopeKeys: 'layered-components', + shareScopeMap, + }); + expect(containerScopeMap['layered-components']).toBe( + shareScopeMap['layered-components'], + ); + expect(containerScopeMap.default).not.toBe(shareScopeMap['layered-components']); + expect(initializedScopes).toContain('layered-components'); + + await runtimeRequire.initContainer(shareScopeMap.default, [], { + shareScopeKeys: 'default', + shareScopeMap, + }); + expect(containerScopeMap.default).toBe(shareScopeMap.default); + expect(containerScopeMap['layered-components']).toBe( + shareScopeMap['layered-components'], + ); + }); + it('does not remap a container-owned scope listed as additional', async () => { // A layered provider makes ShareRuntimeModule list the container's own // scope in additionalInitScopes; the host names that scope differently. @@ -393,12 +433,42 @@ describe('module federation default runtime share scopes', () => { }); // Scalar scopes keep the legacy contract: eager factories are available - // to a synchronous entry, and the scope is initialized lazily. + // to a synchronous entry, installation is never gated, and the scope is + // initialized lazily by the consume handlers (initializing it here would + // start loading eager shares asynchronously). expect(runtimeRequire.m.consume).toBeTypeOf('function'); expect(runtimeRequire.federation.initialConsumesInit).toBeUndefined(); expect(calls).toHaveLength(0); }); + it('waits for a pending scalar scope initialization before consuming from a chunk', async () => { + // A `module`/`promise` remote registers its shares asynchronously; a + // chunk's consumes must not resolve to the local fallback before that. + let resolveExternal; + const external = new Promise(resolve => { + resolveExternal = resolve; + }); + const consumeCalls = []; + const { runtimeRequire } = createRuntime({ + external, + remoteShareScope: 'default', + consumeData: { + shareKey: 'react', + shareScope: 'default', + }, + chunkMapping: { chunk: ['consume'] }, + consumeCalls, + }); + + const promises = []; + runtimeRequire.f.consumes('chunk', promises); + expect(promises).toHaveLength(1); + expect(consumeCalls).toEqual([]); + resolveExternal({ init() {} }); + await Promise.all(promises); + expect(consumeCalls).toEqual(['chunk']); + }); + it('initializes ordered scopes before installing initial consumes', async () => { const calls = []; const enhancedContainer = { diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/node_modules/lib/index.js b/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/node_modules/lib/index.js new file mode 100644 index 000000000000..1aaff98de2ae --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/node_modules/lib/index.js @@ -0,0 +1 @@ +module.exports = "lib"; diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/node_modules/lib/package.json b/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/node_modules/lib/package.json new file mode 100644 index 000000000000..1587a669681c --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/node_modules/lib/package.json @@ -0,0 +1,3 @@ +{ + "version": "1.0.0" +} diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/node_modules/scalar-lib/index.js b/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/node_modules/scalar-lib/index.js new file mode 100644 index 000000000000..22e3346eb88c --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/node_modules/scalar-lib/index.js @@ -0,0 +1 @@ +module.exports = "scalar-lib"; diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/node_modules/scalar-lib/package.json b/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/node_modules/scalar-lib/package.json new file mode 100644 index 000000000000..1587a669681c --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/node_modules/scalar-lib/package.json @@ -0,0 +1,3 @@ +{ + "version": "1.0.0" +} diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/ordered.js b/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/ordered.js new file mode 100644 index 000000000000..4bfbec4b64f1 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/ordered.js @@ -0,0 +1,5 @@ +const lib = require("lib"); + +it("should resolve the ordered eager consume in its entry", () => { + expect(lib).toBe("lib"); +}); diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/rspack.config.js b/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/rspack.config.js new file mode 100644 index 000000000000..9245b0f59207 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/rspack.config.js @@ -0,0 +1,50 @@ +const { ModuleFederationPlugin } = require('@rspack/core').container; +const { ConsumeSharedPlugin, ProvideSharedPlugin } = + require('@rspack/core').sharing; + +// Two entries share one runtime chunk. Only `ordered` has an ordered (array) +// scope consume (contributed by a separately installed enhanced +// ConsumeSharedPlugin, invisible to the high-level `shared` option), but the +// runtime installs the initial consumes of both entries behind that scope's +// initialization, so the startup plan must be decided per runtime and applied +// to every entry it serves. The third config keeps separate runtimes as a +// control: the scalar entry stays synchronous there. +const base = (dir, runtimeChunk) => ({ + entry: { + ordered: './ordered.js', + scalar: './scalar.js', + }, + output: { + filename: `${dir}/[name].js`, + chunkFilename: `${dir}/[name].js`, + uniqueName: `ordered-consume-shared-runtime-${dir}`, + }, + optimization: { runtimeChunk }, + plugins: [ + new ModuleFederationPlugin({ + name: `ordered_consume_shared_runtime_${dir.replace(/-/g, '_')}`, + }), + new ProvideSharedPlugin({ + enhanced: true, + provides: { 'scalar-lib': { shareKey: 'scalar-lib', eager: true } }, + }), + new ConsumeSharedPlugin({ + enhanced: true, + consumes: { + lib: { + shareScope: ['primary', 'default'], + eager: true, + requiredVersion: false, + }, + 'scalar-lib': { eager: true, requiredVersion: false }, + }, + }), + ], +}); + +/** @type {import("@rspack/core").Configuration[]} */ +module.exports = [ + base('shared-scalar-first', 'single'), + base('shared-ordered-first', 'single'), + base('separate', false), +]; diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/scalar.js b/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/scalar.js new file mode 100644 index 000000000000..5578fb59c217 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/scalar.js @@ -0,0 +1,24 @@ +const fs = require("fs"); +const path = require("path"); + +// This entry only has a scalar eager consume, but it shares a runtime with +// `ordered.js`. The runtime installs all initial consumes behind the ordered +// scope's initialization barrier, so this entry must await it as well. +const scalarLib = require("scalar-lib"); + +it("should resolve the scalar eager consume from an entry sharing an async runtime", () => { + expect(scalarLib).toBe("scalar-lib"); +}); + +it("should give every entry the startup mode of its runtime", () => { + // An entry that delegates to a shared runtime gets the synchronous + // federation startup call prepended when its startup mode is decided per + // chunk, even though the runtime it shares with `ordered.js` is + // asynchronous. Every entry must follow its runtime's plan. (The pattern is + // built without the literal: this test's source is part of the bundle.) + const syncStartupCall = new RegExp(["Federation ", "startup call"].join("")); + const hasSyncStartupCall = (name) => + syncStartupCall.test(fs.readFileSync(path.join(__dirname, name), "utf-8")); + expect(hasSyncStartupCall("scalar.js")).toBe(false); + expect(hasSyncStartupCall("ordered.js")).toBe(false); +}); diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/test.config.js b/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/test.config.js new file mode 100644 index 000000000000..d2882be38434 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-shared-runtime/test.config.js @@ -0,0 +1,23 @@ +module.exports = { + findBundle: function (i) { + switch (i) { + // shared runtime, scalar entry first + case 0: + return [ + "./shared-scalar-first/runtime.js", + "./shared-scalar-first/scalar.js", + "./shared-scalar-first/ordered.js", + ]; + // shared runtime, ordered entry first + case 1: + return [ + "./shared-ordered-first/runtime.js", + "./shared-ordered-first/ordered.js", + "./shared-ordered-first/scalar.js", + ]; + // separate runtimes + default: + return ["./separate/scalar.js", "./separate/ordered.js"]; + } + }, +}; From 03726e74ccd0c66c5a5807f6f67fc825b9271a95 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 8 Sep 2026 20:42:49 +0000 Subject: [PATCH 15/27] fix(mf): derive container async mode and preserve lazy sharing --- .../module_federation_runtime_plugin.rs | 79 ++++++++----------- .../runtime/moduleFederationDefaultRuntime.js | 6 +- .../exposed.js | 1 + .../ordered-consume-module-container/index.js | 8 ++ .../ordered.js | 5 ++ .../rspack.config.js | 37 +++++++++ .../test.config.js | 20 +++++ .../container-1-5/share-strategy/index.js | 13 ++- .../share-strategy/rspack.config.js | 3 + 9 files changed, 117 insertions(+), 55 deletions(-) create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/exposed.js create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/index.js create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/ordered.js create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/rspack.config.js create mode 100644 tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/test.config.js diff --git a/crates/rspack_plugin_mf/src/container/module_federation_runtime_plugin.rs b/crates/rspack_plugin_mf/src/container/module_federation_runtime_plugin.rs index 8d946ec3d1fc..f74de7781b7c 100644 --- a/crates/rspack_plugin_mf/src/container/module_federation_runtime_plugin.rs +++ b/crates/rspack_plugin_mf/src/container/module_federation_runtime_plugin.rs @@ -5,10 +5,9 @@ use rspack_cacheable::cacheable; use rspack_core::{ - AsyncModulesArtifact, BoxDependency, ChunkUkey, Compilation, - CompilationAdditionalTreeRuntimeRequirements, CompilationFinishModules, CompilationParams, - CompilerCompilation, CompilerFinishMake, DependencyType, EntryOptions, ExportsInfoArtifact, - Plugin, RuntimeGlobals, RuntimeModule, SideEffectsStateArtifact, SourceType, + BoxDependency, ChunkUkey, Compilation, CompilationAdditionalTreeRuntimeRequirements, + CompilationOptimizeChunkModules, CompilationParams, CompilerCompilation, CompilerFinishMake, + DependencyType, EntryOptions, Plugin, RuntimeGlobals, RuntimeModule, SourceType, }; use rspack_error::Result; use rspack_hook::{plugin, plugin_hook}; @@ -165,49 +164,33 @@ async fn finish_make(&self, compilation: &mut Compilation) -> Result<()> { Ok(()) } -// When MF async startup is enabled, STARTUP may resolve asynchronously even if the entry module -// itself doesn't have top-level await. Mark MF container entry modules as async so downstream -// renderers (e.g. library wrappers) can safely `await` exports without sprinkling MF-specific -// RuntimeGlobals checks across generic plugins. -#[plugin_hook(CompilationFinishModules for ModuleFederationRuntimePlugin, stage = 1000)] -async fn finish_modules( - &self, - compilation: &Compilation, - async_modules_artifact: &mut AsyncModulesArtifact, - _exports_info_artifact: &mut ExportsInfoArtifact, - _side_effects_state_artifact: &mut SideEffectsStateArtifact, -) -> Result<()> { +// Container startup can return a promise independently of module-level await. +// Wait for the chunk graph so async-only consumes and separate runtimes do not +// make synchronous containers acquire top-level await in module-library output. +#[plugin_hook(CompilationOptimizeChunkModules for ModuleFederationRuntimePlugin, stage = -1)] +async fn optimize_chunk_modules(&self, compilation: &mut Compilation) -> Result> { let module_graph = compilation.get_module_graph(); - // The chunk graph does not exist yet, so "initial" is unknown here. Any - // ordered consume may make startup asynchronous, and a container entry that - // is not marked async while startup returns a promise breaks module-library - // output. Over-marking only adds an await of a non-promise. - // ponytail: this marks container entries async for ordered consumes that end - // up async-chunk-only; refine once async-module marking can run after the - // chunk graph is built. - let has_ordered_consume = module_graph.modules().any(|(_, module)| { - module - .as_ref() - .as_any() - .downcast_ref::() - .is_some_and(|module| matches!(module.share_scope(), ShareScope::Multiple(_))) - }); - if !self.options.experiments.async_startup && !has_ordered_consume { - return Ok(()); - } - - for (module_identifier, module) in module_graph.modules() { - if module - .as_ref() - .as_any() - .downcast_ref::() - .is_some() - { - async_modules_artifact.insert(*module_identifier); - } - } - - Ok(()) + let async_containers: Vec<_> = module_graph + .modules() + .filter(|(_, module)| { + module + .as_ref() + .as_any() + .downcast_ref::() + .is_some() + }) + .filter(|(module_identifier, _)| { + compilation + .build_chunk_graph_artifact + .chunk_graph + .get_module_chunks(**module_identifier) + .iter() + .any(|chunk| self.options.experiments.needs_async_startup(compilation, chunk)) + }) + .map(|(module_identifier, _)| *module_identifier) + .collect(); + compilation.async_modules_artifact.extend(async_containers); + Ok(None) } impl Plugin for ModuleFederationRuntimePlugin { @@ -225,8 +208,8 @@ impl Plugin for ModuleFederationRuntimePlugin { ctx .compilation_hooks - .finish_modules - .tap(finish_modules::new(self)); + .optimize_chunk_modules + .tap(optimize_chunk_modules::new(self)); ctx.compiler_hooks.finish_make.tap(finish_make::new(self)); diff --git a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js index 1bcc473e9159..b50f8c29c952 100644 --- a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js +++ b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js @@ -390,11 +390,11 @@ export default function () { ); // Initializes the share scopes of the given consume modules and returns // the promises to wait for before consuming. - // - Chunk path (`includeScalar`): every scope is initialized up front and + // - Chunk path (`includeScalar`): version-first scopes are initialized up front and // awaited, so a remote registered through `I()` (a `module`/`promise` // external initializes asynchronously) contributes its shares before a // consume resolves to a local fallback. Chunk loading is asynchronous - // anyway. + // anyway. Loaded-first scalar consumes keep remote loading lazy. // - Initial path: only ordered (array) scopes, which the bundler runtime // cannot initialize lazily; they enable async startup, which awaits // `initialConsumesInit`. Scalar scopes are left to the consume handlers: @@ -435,7 +435,7 @@ export default function () { }); const initPromises = initializeConsumeShareScopes( consumesLoadingChunkMapping[chunkId], - true, + runtimeRequire.federation.instance.options.shareStrategy !== 'loaded-first', ); if (initPromises.length === 0) return consume(promises); promises.push( diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/exposed.js b/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/exposed.js new file mode 100644 index 000000000000..fa848b439254 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/exposed.js @@ -0,0 +1 @@ +export default 'exposed'; diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/index.js b/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/index.js new file mode 100644 index 000000000000..3c9ededbb496 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/index.js @@ -0,0 +1,8 @@ +it('should export a usable module container with the runtime startup mode', async () => { + const container = await import(/* webpackIgnore: true */ './remoteEntry.mjs'); + expect(typeof container.init).toBe('function'); + expect(typeof container.get).toBe('function'); + await container.init({}); + expect((await container.get('./exposed'))().default).toBe('exposed'); + expect((await import('lib')).default).toBe('lib'); +}); diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/ordered.js b/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/ordered.js new file mode 100644 index 000000000000..490144d1e22e --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/ordered.js @@ -0,0 +1,5 @@ +import lib from 'lib'; + +it('should install the initial ordered consume before its shared-runtime entry runs', () => { + expect(lib).toBe('lib'); +}); diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/rspack.config.js b/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/rspack.config.js new file mode 100644 index 000000000000..c5aa53357842 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/rspack.config.js @@ -0,0 +1,37 @@ +const path = require('path'); +const { ModuleFederationPlugin } = require('@rspack/core').container; +const { ConsumeSharedPlugin } = require('@rspack/core').sharing; + +module.exports = (_, { testPath }) => ['async-only', 'initial'].map(name => ({ + name, + target: 'node', + entry: name === 'initial' + ? { index: './index.js', ordered: './ordered.js' } + : { index: './index.js' }, + experiments: { outputModule: true }, + output: { + path: path.join(testPath, name), + filename: '[name].mjs', + chunkFilename: '[id].mjs', + module: true, + uniqueName: `ordered-consume-module-container-${name}`, + }, + resolve: { + alias: { lib: require.resolve('../ordered-consume-startup/node_modules/lib') }, + }, + optimization: { runtimeChunk: 'single' }, + plugins: [ + new ModuleFederationPlugin({ + name: `ordered_container_${name.replace('-', '_')}`, + filename: 'remoteEntry.mjs', + library: { type: 'module' }, + exposes: { './exposed': './exposed.js' }, + }), + new ConsumeSharedPlugin({ + enhanced: true, + consumes: { + lib: { shareScope: ['primary', 'default'], eager: true, requiredVersion: false }, + }, + }), + ], +})); diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/test.config.js b/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/test.config.js new file mode 100644 index 000000000000..0c12d70cdb6b --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/test.config.js @@ -0,0 +1,20 @@ +const fs = require('fs'); +const path = require('path'); + +module.exports = { + findBundle(i) { + return i === 0 + ? ['async-only/index.mjs'] + : ['initial/ordered.mjs', 'initial/index.mjs']; + }, + afterExecute(configs) { + for (const options of configs) { + const source = fs.readFileSync(path.join(options.output.path, 'remoteEntry.mjs'), 'utf-8'); + if (options.name === 'async-only') { + expect(source).not.toMatch(/\bawait\b/); + } else { + expect(source).toMatch(/\bawait\b/); + } + } + }, +}; diff --git a/tests/rspack-test/configCases/container-1-5/share-strategy/index.js b/tests/rspack-test/configCases/container-1-5/share-strategy/index.js index 2510854b4ef3..d13d07c588d5 100644 --- a/tests/rspack-test/configCases/container-1-5/share-strategy/index.js +++ b/tests/rspack-test/configCases/container-1-5/share-strategy/index.js @@ -1,8 +1,13 @@ -it("should load the component from container", () => { - return import("./App").then(({ default: App }) => { +it("should keep remotes lazy when consuming a loaded-first share from a chunk", async () => { + try { + const { default: App } = await import("./App"); expect(App()).toBe("App rendered with [This is react 0.1.2]"); const shareStrategy = __webpack_require__.federation.initOptions.shareStrategy; expect(shareStrategy).toBe("loaded-first"); - - }); + expect(globalThis.__loadedFirstRemoteLoads || 0).toBe(0); + expect((await import("lazy/module")).default).toBe("remote"); + expect(globalThis.__loadedFirstRemoteLoads).toBe(1); + } finally { + delete globalThis.__loadedFirstRemoteLoads; + } }); diff --git a/tests/rspack-test/configCases/container-1-5/share-strategy/rspack.config.js b/tests/rspack-test/configCases/container-1-5/share-strategy/rspack.config.js index b0439e3f2672..c62de775035f 100644 --- a/tests/rspack-test/configCases/container-1-5/share-strategy/rspack.config.js +++ b/tests/rspack-test/configCases/container-1-5/share-strategy/rspack.config.js @@ -9,6 +9,9 @@ module.exports = { plugins: [ new ModuleFederationPlugin({ shareStrategy: 'loaded-first', + remotes: { + lazy: `promise (globalThis.__loadedFirstRemoteLoads = (globalThis.__loadedFirstRemoteLoads || 0) + 1, Promise.resolve({ init() {}, get() { return () => 'remote'; } }))`, + }, shared: { react: { version: false, From ca09e3d1b64e3c4ac42c471631616b7252ea6a56 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 8 Sep 2026 21:06:19 +0000 Subject: [PATCH 16/27] fix(mf): register additional providers for ordered host scopes --- .../runtime/moduleFederationDefaultRuntime.js | 21 ++++-- .../ModuleFederationRuntime.test.js | 23 ++++++ .../rspack.config.js | 72 ++++++++++--------- 3 files changed, 77 insertions(+), 39 deletions(-) diff --git a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js index b50f8c29c952..e9625fbfc768 100644 --- a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js +++ b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js @@ -435,7 +435,8 @@ export default function () { }); const initPromises = initializeConsumeShareScopes( consumesLoadingChunkMapping[chunkId], - runtimeRequire.federation.instance.options.shareStrategy !== 'loaded-first', + runtimeRequire.federation.instance.options.shareStrategy !== + 'loaded-first', ); if (initPromises.length === 0) return consume(promises); promises.push( @@ -490,16 +491,22 @@ export default function () { const hostShareScopeMap = remoteEntryInitOptions?.shareScopeMap; let options = remoteEntryInitOptions; const additionalScopes = []; - if ( - additionalContainerInitScopes?.length && - hostShareScopeMap && - !Array.isArray(remoteEntryInitOptions.shareScopeKeys) - ) { + if (additionalContainerInitScopes?.length && hostShareScopeMap) { const hostScope = remoteEntryInitOptions.shareScopeKeys || 'default'; const containerScopes = Array.isArray(containerShareScope) ? containerShareScope : [containerShareScope || 'default']; - if ( + if (Array.isArray(hostScope)) { + // The bundler binds every host scope, but initializes only the + // container's primary scopes. Register additional providers too. + additionalScopes.push( + ...hostScope.filter( + (scope) => + additionalContainerInitScopes.includes(scope) && + !containerScopes.includes(scope), + ), + ); + } else if ( !containerScopes.includes(hostScope) && additionalContainerInitScopes.includes(hostScope) ) { diff --git a/tests/rspack-test/ModuleFederationRuntime.test.js b/tests/rspack-test/ModuleFederationRuntime.test.js index d7f7b90023c9..4d7a075b3a7f 100644 --- a/tests/rspack-test/ModuleFederationRuntime.test.js +++ b/tests/rspack-test/ModuleFederationRuntime.test.js @@ -313,6 +313,29 @@ describe('module federation default runtime share scopes', () => { ); }); + it('initializes additional providers after binding an array of host scopes', async () => { + const { initializedScopes, runtimeRequire, shareScopeMap: containerScopeMap } = + createRuntime({ + realInitContainerEntry: true, + containerShareScope: 'default', + remoteShareScope: 'default', + additionalInitScopes: ['default', 'react-layer', 'private'], + }); + const shareScopeMap = { default: {}, 'react-layer': {}, unrelated: {} }; + containerScopeMap['react-layer'] = { old: true }; + + await runtimeRequire.initContainer(shareScopeMap['react-layer'], [], { + shareScopeKeys: ['react-layer', 'default', 'unrelated'], + shareScopeMap, + }); + + expect(containerScopeMap.default).toBe(shareScopeMap.default); + expect(containerScopeMap['react-layer']).toBe(shareScopeMap['react-layer']); + expect(initializedScopes.filter(scope => scope === 'react-layer')).toHaveLength(1); + expect(initializedScopes).not.toContain('private'); + expect(initializedScopes).not.toContain('unrelated'); + }); + it('does not remap a container-owned scope listed as additional', async () => { // A layered provider makes ShareRuntimeModule list the container's own // scope in additionalInitScopes; the host names that scope differently. diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/rspack.config.js b/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/rspack.config.js index c5aa53357842..5553332f9712 100644 --- a/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/rspack.config.js +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/rspack.config.js @@ -2,36 +2,44 @@ const path = require('path'); const { ModuleFederationPlugin } = require('@rspack/core').container; const { ConsumeSharedPlugin } = require('@rspack/core').sharing; -module.exports = (_, { testPath }) => ['async-only', 'initial'].map(name => ({ - name, - target: 'node', - entry: name === 'initial' - ? { index: './index.js', ordered: './ordered.js' } - : { index: './index.js' }, - experiments: { outputModule: true }, - output: { - path: path.join(testPath, name), - filename: '[name].mjs', - chunkFilename: '[id].mjs', - module: true, - uniqueName: `ordered-consume-module-container-${name}`, - }, - resolve: { - alias: { lib: require.resolve('../ordered-consume-startup/node_modules/lib') }, - }, - optimization: { runtimeChunk: 'single' }, - plugins: [ - new ModuleFederationPlugin({ - name: `ordered_container_${name.replace('-', '_')}`, - filename: 'remoteEntry.mjs', - library: { type: 'module' }, - exposes: { './exposed': './exposed.js' }, - }), - new ConsumeSharedPlugin({ - enhanced: true, - consumes: { - lib: { shareScope: ['primary', 'default'], eager: true, requiredVersion: false }, +module.exports = (_, { testPath }) => + ['async-only', 'initial'].map((name) => ({ + name, + target: 'node', + entry: + name === 'initial' + ? { index: './index.js', ordered: './ordered.js' } + : { index: './index.js' }, + experiments: { outputModule: true }, + output: { + path: path.join(testPath, name), + filename: '[name].mjs', + chunkFilename: '[id].mjs', + module: true, + uniqueName: `ordered-consume-module-container-${name}`, + }, + resolve: { + alias: { + lib: require.resolve('../ordered-consume-startup/node_modules/lib'), }, - }), - ], -})); + }, + optimization: { runtimeChunk: 'single' }, + plugins: [ + new ModuleFederationPlugin({ + name: `ordered_container_${name.replace('-', '_')}`, + filename: 'remoteEntry.mjs', + library: { type: 'module' }, + exposes: { './exposed': './exposed.js' }, + }), + new ConsumeSharedPlugin({ + enhanced: true, + consumes: { + lib: { + shareScope: ['primary', 'default'], + eager: true, + requiredVersion: false, + }, + }, + }), + ], + })); From 43de58026e06e89152ccc6b1a8f73fd82af4aba9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 8 Sep 2026 21:28:22 +0000 Subject: [PATCH 17/27] chore(mf): format runtime startup hook --- .../src/container/module_federation_runtime_plugin.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/rspack_plugin_mf/src/container/module_federation_runtime_plugin.rs b/crates/rspack_plugin_mf/src/container/module_federation_runtime_plugin.rs index f74de7781b7c..a4863df1e7f0 100644 --- a/crates/rspack_plugin_mf/src/container/module_federation_runtime_plugin.rs +++ b/crates/rspack_plugin_mf/src/container/module_federation_runtime_plugin.rs @@ -185,7 +185,12 @@ async fn optimize_chunk_modules(&self, compilation: &mut Compilation) -> Result< .chunk_graph .get_module_chunks(**module_identifier) .iter() - .any(|chunk| self.options.experiments.needs_async_startup(compilation, chunk)) + .any(|chunk| { + self + .options + .experiments + .needs_async_startup(compilation, chunk) + }) }) .map(|(module_identifier, _)| *module_identifier) .collect(); From 3c2da7d62b78a677da0f7e55e7186518eb7166cc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 8 Sep 2026 23:19:58 +0000 Subject: [PATCH 18/27] docs(mf): explain ordered share scope initialization --- website/docs/en/plugins/module-federation-plugin.mdx | 4 ++++ website/docs/zh/plugins/module-federation-plugin.mdx | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/website/docs/en/plugins/module-federation-plugin.mdx b/website/docs/en/plugins/module-federation-plugin.mdx index 47d8112f5ac2..544b6fa856a4 100644 --- a/website/docs/en/plugins/module-federation-plugin.mdx +++ b/website/docs/en/plugins/module-federation-plugin.mdx @@ -83,6 +83,10 @@ Define the output format of remote container entry. The default libraryType is " Define the namespace for shared dependencies in the current container. By configuring share scopes between different containers, the sharing behavior of modules can be controlled, including determining which modules are shared between different containers. The default share scope is `"default"`. +An array preserves scope order. For example, `['custom', 'default']` uses `"custom"` as the primary scope when initializing a legacy container. Enhanced containers receive the complete ordered scope list. The scopes keep separate shared registries. + +An array must contain at least one scope. Multiple scopes require the enhanced runtime used by `ModuleFederationPlugin`, or `enhanced: true` when using the lower-level federation plugins. Without the enhanced runtime, a single-element array is treated as a string and arrays with multiple entries are rejected. These rules also apply to `remotes.*.shareScope` and `shared.*.shareScope`. + ### shareStrategy - Type: `'version-first' | 'loaded-first'` diff --git a/website/docs/zh/plugins/module-federation-plugin.mdx b/website/docs/zh/plugins/module-federation-plugin.mdx index 871f5ef79935..c2ae0df56a33 100644 --- a/website/docs/zh/plugins/module-federation-plugin.mdx +++ b/website/docs/zh/plugins/module-federation-plugin.mdx @@ -83,6 +83,10 @@ export default { 定义当前应用共享依赖的命名空间。通过在不同的应用之间配置命名空间,可以控制模块的共享行为,包括确定哪些模块在不同的应用之间是共享的。默认的命名空间为 `"default"`。 +数组会保留共享作用域的顺序。例如,`['custom', 'default']` 在初始化旧版容器时使用 `"custom"` 作为主作用域。增强容器会接收完整的有序作用域列表,各作用域保留独立的共享模块注册表。 + +数组必须至少包含一个作用域。多个作用域需要 `ModuleFederationPlugin` 使用的增强运行时,或在使用底层联邦插件时设置 `enhanced: true`。不使用增强运行时时,单元素数组会被视为字符串,包含多个元素的数组会被拒绝。这些规则也适用于 `remotes.*.shareScope` 和 `shared.*.shareScope`。 + ### shareStrategy - 类型:`'version-first' | 'loaded-first'` From ec919f55d66e5fb931d2c9d0cfbe3917dc77e05d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 8 Sep 2026 23:24:47 +0000 Subject: [PATCH 19/27] test(mf): run runtime adapter cases in the compiler harness --- .../ModuleFederationRuntime.test.js | 650 --------------- .../module-federation-runtime.js | 750 ++++++++++++++++++ 2 files changed, 750 insertions(+), 650 deletions(-) delete mode 100644 tests/rspack-test/ModuleFederationRuntime.test.js create mode 100644 tests/rspack-test/compilerCases/module-federation-runtime.js diff --git a/tests/rspack-test/ModuleFederationRuntime.test.js b/tests/rspack-test/ModuleFederationRuntime.test.js deleted file mode 100644 index 4d7a075b3a7f..000000000000 --- a/tests/rspack-test/ModuleFederationRuntime.test.js +++ /dev/null @@ -1,650 +0,0 @@ -const runtime = require('../../packages/rspack/src/runtime/moduleFederationDefaultRuntime.js').default; -const { - bundlerRuntime, -} = require('@module-federation/runtime-tools/webpack-bundler-runtime'); - -const magicNames = [ - '__module_federation_bundler_runtime__', - '__module_federation_runtime_plugins__', - '__module_federation_remote_infos__', - '__module_federation_container_name__', - '__module_federation_share_strategy__', - '__module_federation_share_fallbacks__', - '__module_federation_share_fallback_variants__', - '__module_federation_library_type__', - '__module_federation_runtime_require__', -]; - -function createRuntime({ - external, - externalType = 'commonjs-module', - remoteShareScope = ['primary', 'secondary'], - containerShareScope = 'container-custom', - hasContainer = true, - sharedFallback, - sharedFallbackVariants, - consumeData, - initialConsumes, - chunkMapping = {}, - consumeCalls = [], - additionalInitScopes = [], - scopeToSharingDataMapping = {}, - // Use the real bundler-runtime initContainerEntry instead of recording - // its arguments; the runtime under test then gets its own scope map. - realInitContainerEntry = false, -} = {}) { - const shareScopeMap = realInitContainerEntry - ? {} - : { - primary: { tag: 'primary' }, - secondary: { tag: 'secondary' }, - 'host-custom': { tag: 'host-custom' }, - }; - const runtimeRequire = () => external; - Object.assign(runtimeRequire, { - S: shareScopeMap, - c: {}, - f: {}, - I() {}, - m: {}, - federation: {}, - initializeSharingData: { - scopeToSharingDataMapping, - additionalInitScopes, - }, - remotesLoadingData: { - chunkMapping: {}, - moduleIdToRemoteDataMapping: { - remote: { - shareScope: remoteShareScope, - name: './module', - externalModuleId: 'external', - remoteName: 'remote', - }, - }, - }, - }); - if (consumeData) { - runtimeRequire.f.consumes = () => {}; - runtimeRequire.consumesLoadingData = { - chunkMapping, - initialConsumes, - moduleIdToConsumeDataMapping: { consume: consumeData }, - }; - } - if (hasContainer) { - runtimeRequire.initContainer = () => {}; - runtimeRequire.initializeExposesData = { - moduleMap: {}, - shareScope: containerShareScope, - }; - } - - const matchedScopes = []; - const initializedScopes = []; - const initContainerCalls = []; - const remote = { name: 'remote', shareScope: remoteShareScope }; - const instance = { - name: 'host', - options: { - remotes: [remote], - shareStrategy: 'version-first', - }, - shareScopeMap, - sharedHandler: { - initializeSharing(shareScope) { - initializedScopes.push(shareScope); - if (instance.options.remotes.some(item => item.shareScope === shareScope)) { - matchedScopes.push(shareScope); - } - return []; - }, - }, - initializeSharing(shareScope, options) { - return this.sharedHandler.initializeSharing(shareScope, options); - }, - initOptions() {}, - initShareScopeMap(key, scope) { - this.shareScopeMap[key] = scope; - }, - registerShared() {}, - }; - const localBundlerRuntime = { - ...bundlerRuntime, - consumes: options => { - consumeCalls.push(options.chunkId); - }, - init: () => instance, - getSharedFallbackGetter: ({ shareKey }) => shareKey, - initContainerEntry: realInitContainerEntry - ? bundlerRuntime.initContainerEntry - : options => { - initContainerCalls.push(options); - }, - }; - const importedBundlerRuntime = { - ...localBundlerRuntime, - bundlerRuntime: localBundlerRuntime, - }; - const remoteInfos = { - remote: [ - { - alias: 'remote', - name: externalType === 'script' ? 'remote' : undefined, - externalType, - shareScope: remoteShareScope, - }, - ], - }; - const instantiate = new Function( - ...magicNames, - `return (${runtime.toString()});`, - ); - instantiate( - importedBundlerRuntime, - [], - remoteInfos, - 'host', - 'version-first', - sharedFallback, - sharedFallbackVariants, - 'commonjs-module', - runtimeRequire, - )(); - - return { - initContainerCalls, - initializedScopes, - instance, - matchedScopes, - remote, - runtimeRequire, - shareScopeMap, - }; -} - -async function waitForInitialConsume(runtimeRequire) { - for (let index = 0; index < 10; index++) { - if (typeof runtimeRequire.m.consume === 'function') return; - await Promise.resolve(); - } - expect(runtimeRequire.m.consume).toBeTypeOf('function'); -} - -describe('module federation default runtime share scopes', () => { - it('preserves scalar custom-scope container initialization', () => { - const { initContainerCalls, instance, runtimeRequire, shareScopeMap } = - createRuntime({ remoteShareScope: 'host-custom' }); - const remoteEntryInitOptions = { shareScopeKeys: 'host-custom' }; - Object.defineProperty(remoteEntryInitOptions, 'shareScopeMap', { - value: shareScopeMap, - }); - - runtimeRequire.initContainer( - shareScopeMap['host-custom'], - [], - remoteEntryInitOptions, - ); - - expect(initContainerCalls).toHaveLength(1); - expect(initContainerCalls[0]).toMatchObject({ - shareScope: shareScopeMap['host-custom'], - shareScopeKey: 'container-custom', - remoteEntryInitOptions, - }); - expect(instance.options.remotes[0].shareScope).toBe('host-custom'); - expect(runtimeRequire.initContainer).toHaveLength(3); - }); - - it('expands container init only by declared additional scopes', async () => { - const { initContainerCalls, runtimeRequire, shareScopeMap } = createRuntime({ - remoteShareScope: 'host-custom', - additionalInitScopes: ['secondary'], - scopeToSharingDataMapping: { - 'host-custom': [], - secondary: [], - }, - }); - const remoteEntryInitOptions = { shareScopeKeys: 'host-custom' }; - Object.defineProperty(remoteEntryInitOptions, 'shareScopeMap', { - value: shareScopeMap, - }); - - await runtimeRequire.initContainer( - shareScopeMap['host-custom'], - [], - remoteEntryInitOptions, - ); - - // The host's options reach the bundler runtime untouched (scalar - // primary binding); additional scopes are mapped separately. - expect(initContainerCalls).toHaveLength(1); - expect(initContainerCalls[0].remoteEntryInitOptions).toBe( - remoteEntryInitOptions, - ); - expect(remoteEntryInitOptions.shareScopeKeys).toBe('host-custom'); - expect(shareScopeMap.secondary).toBe(remoteEntryInitOptions.shareScopeMap.secondary); - }); - - describe('with the real initContainerEntry', () => { - const hostShareScopeMap = () => ({ - 'host-custom': { tag: 'host-custom' }, - secondary: { tag: 'secondary' }, - }); - - it('binds the container primary scope to the host scope object', async () => { - const { runtimeRequire, shareScopeMap: containerScopeMap } = - createRuntime({ - realInitContainerEntry: true, - containerShareScope: 'container-custom', - remoteShareScope: 'host-custom', - }); - const shareScopeMap = hostShareScopeMap(); - - await runtimeRequire.initContainer(shareScopeMap['host-custom'], [], { - shareScopeKeys: 'host-custom', - shareScopeMap, - }); - - expect(containerScopeMap['container-custom']).toBe( - shareScopeMap['host-custom'], - ); - }); - - it('keeps the primary binding when expanding additional scopes', async () => { - const { initializedScopes, runtimeRequire, shareScopeMap: containerScopeMap } = - createRuntime({ - realInitContainerEntry: true, - containerShareScope: 'container-custom', - remoteShareScope: 'host-custom', - additionalInitScopes: ['secondary'], - }); - const shareScopeMap = hostShareScopeMap(); - - await runtimeRequire.initContainer(shareScopeMap['host-custom'], [], { - shareScopeKeys: 'host-custom', - shareScopeMap, - }); - - // primary alias: container name -> host object - expect(containerScopeMap['container-custom']).toBe( - shareScopeMap['host-custom'], - ); - // additional scope shares the host's object and gets initialized - expect(containerScopeMap.secondary).toBe(shareScopeMap.secondary); - expect(initializedScopes).toContain('secondary'); - // the scalar path never registers the host's name on the container - expect(containerScopeMap['host-custom']).toBeUndefined(); - }); - - it('binds an additional scope by name when the host initializes it, leaving the primary pool alone', async () => { - // Hosts initialize a container once per shared scope. Initializing the - // container's additional `layered-components` scope must bind that - // scope only; aliasing the primary `default` pool to it mixes scopes. - const { initializedScopes, runtimeRequire, shareScopeMap: containerScopeMap } = - createRuntime({ - realInitContainerEntry: true, - containerShareScope: 'default', - remoteShareScope: 'default', - additionalInitScopes: ['layered-components'], - }); - const shareScopeMap = { - default: { tag: 'host-default' }, - 'layered-components': { tag: 'host-layered' }, - }; - - await runtimeRequire.initContainer(shareScopeMap['layered-components'], [], { - shareScopeKeys: 'layered-components', - shareScopeMap, - }); - expect(containerScopeMap['layered-components']).toBe( - shareScopeMap['layered-components'], - ); - expect(containerScopeMap.default).not.toBe(shareScopeMap['layered-components']); - expect(initializedScopes).toContain('layered-components'); - - await runtimeRequire.initContainer(shareScopeMap.default, [], { - shareScopeKeys: 'default', - shareScopeMap, - }); - expect(containerScopeMap.default).toBe(shareScopeMap.default); - expect(containerScopeMap['layered-components']).toBe( - shareScopeMap['layered-components'], - ); - }); - - it('initializes additional providers after binding an array of host scopes', async () => { - const { initializedScopes, runtimeRequire, shareScopeMap: containerScopeMap } = - createRuntime({ - realInitContainerEntry: true, - containerShareScope: 'default', - remoteShareScope: 'default', - additionalInitScopes: ['default', 'react-layer', 'private'], - }); - const shareScopeMap = { default: {}, 'react-layer': {}, unrelated: {} }; - containerScopeMap['react-layer'] = { old: true }; - - await runtimeRequire.initContainer(shareScopeMap['react-layer'], [], { - shareScopeKeys: ['react-layer', 'default', 'unrelated'], - shareScopeMap, - }); - - expect(containerScopeMap.default).toBe(shareScopeMap.default); - expect(containerScopeMap['react-layer']).toBe(shareScopeMap['react-layer']); - expect(initializedScopes.filter(scope => scope === 'react-layer')).toHaveLength(1); - expect(initializedScopes).not.toContain('private'); - expect(initializedScopes).not.toContain('unrelated'); - }); - - it('does not remap a container-owned scope listed as additional', async () => { - // A layered provider makes ShareRuntimeModule list the container's own - // scope in additionalInitScopes; the host names that scope differently. - const { initializedScopes, runtimeRequire, shareScopeMap: containerScopeMap } = - createRuntime({ - realInitContainerEntry: true, - containerShareScope: 'container-custom', - remoteShareScope: 'host-custom', - additionalInitScopes: ['container-custom', 'secondary'], - }); - const shareScopeMap = hostShareScopeMap(); - - await runtimeRequire.initContainer(shareScopeMap['host-custom'], [], { - shareScopeKeys: 'host-custom', - shareScopeMap, - }); - - expect(containerScopeMap['container-custom']).toBe( - shareScopeMap['host-custom'], - ); - expect(shareScopeMap['container-custom']).toBeUndefined(); - expect(containerScopeMap.secondary).toBe(shareScopeMap.secondary); - expect(initializedScopes.filter(scope => scope === 'secondary')).toHaveLength(1); - }); - }); - - it('does not post-initialize scopes owned by the container', async () => { - const { initializedScopes, runtimeRequire, shareScopeMap } = createRuntime({ - containerShareScope: 'secondary', - additionalInitScopes: ['secondary'], - }); - const remoteEntryInitOptions = { - shareScopeKeys: 'primary', - shareScopeMap, - }; - - await runtimeRequire.initContainer( - shareScopeMap.primary, - [], - remoteEntryInitOptions, - ); - - expect(initializedScopes).toEqual([]); - }); - - it.each(['commonjs-module', 'module'])( - 'initializes a legacy %s remote once with the primary array scope', - async externalType => { - const calls = []; - const legacyContainer = { - init(shareScope, initScope) { - calls.push([shareScope, initScope]); - }, - }; - const external = - externalType === 'module' - ? Promise.resolve(legacyContainer) - : legacyContainer; - const { runtimeRequire, shareScopeMap } = createRuntime({ - external, - externalType, - }); - - await runtimeRequire.I(['primary', 'secondary'], []); - - expect(calls).toHaveLength(1); - expect(calls[0]).toHaveLength(2); - expect(calls[0][0]).toBe(shareScopeMap.primary); - }, - ); - - it('matches every configured script-remote scope during version-first initialization', async () => { - const { matchedScopes, remote, runtimeRequire } = createRuntime({ - externalType: 'script', - hasContainer: false, - }); - - await runtimeRequire.I(['primary', 'secondary'], []); - - expect(matchedScopes).toEqual(['primary', 'secondary']); - expect(remote.shareScope).toEqual(['primary', 'secondary']); - }); - - it('passes the full ordered scope capability to an enhanced remote once', async () => { - const calls = []; - const enhancedContainer = { - init(...args) { - calls.push(args); - }, - }; - const { runtimeRequire, shareScopeMap } = createRuntime({ - external: enhancedContainer, - }); - - await runtimeRequire.I(['primary', 'secondary'], []); - - expect(calls).toHaveLength(1); - expect(calls[0]).toHaveLength(3); - expect(calls[0][0]).toBe(shareScopeMap.primary); - expect(calls[0][2].shareScopeKeys).toEqual(['primary', 'secondary']); - }); - - it('installs scalar initial consumes synchronously', () => { - const calls = []; - const enhancedContainer = { - init(...args) { - calls.push(args); - }, - }; - const { runtimeRequire } = createRuntime({ - external: enhancedContainer, - remoteShareScope: 'default', - consumeData: { - shareKey: 'react', - shareScope: 'default', - }, - initialConsumes: ['consume'], - }); - - // Scalar scopes keep the legacy contract: eager factories are available - // to a synchronous entry, installation is never gated, and the scope is - // initialized lazily by the consume handlers (initializing it here would - // start loading eager shares asynchronously). - expect(runtimeRequire.m.consume).toBeTypeOf('function'); - expect(runtimeRequire.federation.initialConsumesInit).toBeUndefined(); - expect(calls).toHaveLength(0); - }); - - it('waits for a pending scalar scope initialization before consuming from a chunk', async () => { - // A `module`/`promise` remote registers its shares asynchronously; a - // chunk's consumes must not resolve to the local fallback before that. - let resolveExternal; - const external = new Promise(resolve => { - resolveExternal = resolve; - }); - const consumeCalls = []; - const { runtimeRequire } = createRuntime({ - external, - remoteShareScope: 'default', - consumeData: { - shareKey: 'react', - shareScope: 'default', - }, - chunkMapping: { chunk: ['consume'] }, - consumeCalls, - }); - - const promises = []; - runtimeRequire.f.consumes('chunk', promises); - expect(promises).toHaveLength(1); - expect(consumeCalls).toEqual([]); - resolveExternal({ init() {} }); - await Promise.all(promises); - expect(consumeCalls).toEqual(['chunk']); - }); - - it('initializes ordered scopes before installing initial consumes', async () => { - const calls = []; - const enhancedContainer = { - init(...args) { - calls.push(args); - }, - }; - const { runtimeRequire, shareScopeMap } = createRuntime({ - external: enhancedContainer, - consumeData: { - shareKey: 'react', - shareScope: ['primary', 'secondary'], - }, - initialConsumes: ['consume'], - }); - - expect(calls).toHaveLength(1); - expect(calls[0][0]).toBe(shareScopeMap.primary); - expect(calls[0][2].shareScopeKeys).toEqual(['primary', 'secondary']); - await waitForInitialConsume(runtimeRequire); - }); - - it('waits for ordered scopes before installing initial consumes', async () => { - let resolveInit; - const initPromise = new Promise(resolve => { - resolveInit = resolve; - }); - const enhancedContainer = { - init() { - return initPromise; - }, - }; - const { runtimeRequire } = createRuntime({ - external: enhancedContainer, - consumeData: { - shareKey: 'react', - shareScope: ['primary', 'secondary'], - }, - initialConsumes: ['consume'], - }); - - expect(runtimeRequire.m.consume).toBeUndefined(); - const startupPromises = []; - runtimeRequire.f.consumes('startup', startupPromises); - expect(startupPromises).toContain( - runtimeRequire.federation.initialConsumesInit, - ); - resolveInit(); - await waitForInitialConsume(runtimeRequire); - }); - - it('initializes a frozen legacy container without proxying its exports', async () => { - const calls = []; - const external = Object.freeze({ - init(...args) { - calls.push(args); - }, - }); - const { runtimeRequire, shareScopeMap } = createRuntime({ external }); - - await runtimeRequire.I(['primary', 'secondary'], []); - - expect(calls).toHaveLength(1); - expect(calls[0][0]).toBe(shareScopeMap.primary); - }); - - it('does not retry a legacy container whose init throws synchronously', async () => { - let calls = 0; - const external = { - init() { - calls += 1; - throw new Error('legacy init failed'); - }, - }; - const { runtimeRequire } = createRuntime({ external }); - - await runtimeRequire.I(['primary', 'secondary'], []); - - expect(calls).toBe(1); - }); - - it('selects tree-shaking fallbacks by scope and layer identity', () => { - const sharedFallback = { - react: [ - ['legacy.js', '1.0.0', 'legacy'], - ['server.js', '1.0.0', 'server'], - ], - }; - const { runtimeRequire } = createRuntime({ - sharedFallback, - sharedFallbackVariants: { - react: [ - { - entry: 'legacy.js', - version: '1.0.0', - globalName: 'legacy', - shareScope: 'primary', - import: 'react', - }, - { - entry: 'server.js', - version: '1.0.0', - globalName: 'server', - shareScope: 'primary', - layer: 'server', - import: 'react-server', - }, - ], - }, - consumeData: { - shareKey: 'react', - shareScope: 'primary', - layer: 'server', - import: 'react-server', - }, - }); - - const fallbackKey = - runtimeRequire.federation.consumesLoadingModuleToHandlerMapping.consume - .getter; - expect(fallbackKey).toBe('react\0consume'); - expect(runtimeRequire.federation.sharedFallback[fallbackKey]).toEqual([ - ['server.js', '1.0.0', 'server'], - ]); - expect(sharedFallback.react).toHaveLength(2); - }); - - it('uses the consume fallback when no tree-shaking variant matches', () => { - const fallback = () => 'local'; - const { runtimeRequire } = createRuntime({ - sharedFallback: { react: [['client.js', '1.0.0', 'client']] }, - sharedFallbackVariants: { - react: [ - { - entry: 'client.js', - version: '1.0.0', - globalName: 'client', - shareScope: 'client', - import: 'react-client', - }, - ], - }, - consumeData: { - shareKey: 'react', - shareScope: 'server', - import: 'react-server', - fallback, - }, - }); - - expect( - runtimeRequire.federation.consumesLoadingModuleToHandlerMapping.consume - .getter, - ).toBe(fallback); - }); -}); diff --git a/tests/rspack-test/compilerCases/module-federation-runtime.js b/tests/rspack-test/compilerCases/module-federation-runtime.js new file mode 100644 index 000000000000..be298ce2e95f --- /dev/null +++ b/tests/rspack-test/compilerCases/module-federation-runtime.js @@ -0,0 +1,750 @@ +const runtime = + require('../../../packages/rspack/src/runtime/moduleFederationDefaultRuntime.js').default; +const { + bundlerRuntime, +} = require('@module-federation/runtime-tools/webpack-bundler-runtime'); + +const magicNames = [ + '__module_federation_bundler_runtime__', + '__module_federation_runtime_plugins__', + '__module_federation_remote_infos__', + '__module_federation_container_name__', + '__module_federation_share_strategy__', + '__module_federation_share_fallbacks__', + '__module_federation_share_fallback_variants__', + '__module_federation_library_type__', + '__module_federation_runtime_require__', +]; + +function createRuntime({ + external, + externalType = 'commonjs-module', + remoteShareScope = ['primary', 'secondary'], + containerShareScope = 'container-custom', + hasContainer = true, + sharedFallback, + sharedFallbackVariants, + consumeData, + initialConsumes, + chunkMapping = {}, + consumeCalls = [], + additionalInitScopes = [], + scopeToSharingDataMapping = {}, + // Use the real bundler-runtime initContainerEntry instead of recording + // its arguments; the runtime under test then gets its own scope map. + realInitContainerEntry = false, +} = {}) { + const shareScopeMap = realInitContainerEntry + ? {} + : { + primary: { tag: 'primary' }, + secondary: { tag: 'secondary' }, + 'host-custom': { tag: 'host-custom' }, + }; + const runtimeRequire = () => external; + Object.assign(runtimeRequire, { + S: shareScopeMap, + c: {}, + f: {}, + I() {}, + m: {}, + federation: {}, + initializeSharingData: { + scopeToSharingDataMapping, + additionalInitScopes, + }, + remotesLoadingData: { + chunkMapping: {}, + moduleIdToRemoteDataMapping: { + remote: { + shareScope: remoteShareScope, + name: './module', + externalModuleId: 'external', + remoteName: 'remote', + }, + }, + }, + }); + if (consumeData) { + runtimeRequire.f.consumes = () => {}; + runtimeRequire.consumesLoadingData = { + chunkMapping, + initialConsumes, + moduleIdToConsumeDataMapping: { consume: consumeData }, + }; + } + if (hasContainer) { + runtimeRequire.initContainer = () => {}; + runtimeRequire.initializeExposesData = { + moduleMap: {}, + shareScope: containerShareScope, + }; + } + + const matchedScopes = []; + const initializedScopes = []; + const initContainerCalls = []; + const remote = { name: 'remote', shareScope: remoteShareScope }; + const instance = { + name: 'host', + options: { + remotes: [remote], + shareStrategy: 'version-first', + }, + shareScopeMap, + sharedHandler: { + initializeSharing(shareScope) { + initializedScopes.push(shareScope); + if ( + instance.options.remotes.some( + (item) => item.shareScope === shareScope, + ) + ) { + matchedScopes.push(shareScope); + } + return []; + }, + }, + initializeSharing(shareScope, options) { + return this.sharedHandler.initializeSharing(shareScope, options); + }, + initOptions() {}, + initShareScopeMap(key, scope) { + this.shareScopeMap[key] = scope; + }, + registerShared() {}, + }; + const localBundlerRuntime = { + ...bundlerRuntime, + consumes: (options) => { + consumeCalls.push(options.chunkId); + }, + init: () => instance, + getSharedFallbackGetter: ({ shareKey }) => shareKey, + initContainerEntry: realInitContainerEntry + ? bundlerRuntime.initContainerEntry + : (options) => { + initContainerCalls.push(options); + }, + }; + const importedBundlerRuntime = { + ...localBundlerRuntime, + bundlerRuntime: localBundlerRuntime, + }; + const remoteInfos = { + remote: [ + { + alias: 'remote', + name: externalType === 'script' ? 'remote' : undefined, + externalType, + shareScope: remoteShareScope, + }, + ], + }; + const instantiate = new Function( + ...magicNames, + `return (${runtime.toString()});`, + ); + instantiate( + importedBundlerRuntime, + [], + remoteInfos, + 'host', + 'version-first', + sharedFallback, + sharedFallbackVariants, + 'commonjs-module', + runtimeRequire, + )(); + + return { + initContainerCalls, + initializedScopes, + instance, + matchedScopes, + remote, + runtimeRequire, + shareScopeMap, + }; +} + +async function waitForInitialConsume(runtimeRequire) { + for (let index = 0; index < 10; index++) { + if (typeof runtimeRequire.m.consume === 'function') return; + await Promise.resolve(); + } + expect(runtimeRequire.m.consume).toBeTypeOf('function'); +} + +const hostShareScopeMap = () => ({ + 'host-custom': { tag: 'host-custom' }, + secondary: { tag: 'secondary' }, +}); + +/** @type {import('@rspack/test-tools').TCompilerCaseConfig[]} */ +module.exports = [ + { + description: 'preserves scalar custom-scope container initialization', + build() {}, + check: () => { + const { initContainerCalls, instance, runtimeRequire, shareScopeMap } = + createRuntime({ remoteShareScope: 'host-custom' }); + const remoteEntryInitOptions = { shareScopeKeys: 'host-custom' }; + Object.defineProperty(remoteEntryInitOptions, 'shareScopeMap', { + value: shareScopeMap, + }); + + runtimeRequire.initContainer( + shareScopeMap['host-custom'], + [], + remoteEntryInitOptions, + ); + + expect(initContainerCalls).toHaveLength(1); + expect(initContainerCalls[0]).toMatchObject({ + shareScope: shareScopeMap['host-custom'], + shareScopeKey: 'container-custom', + remoteEntryInitOptions, + }); + expect(instance.options.remotes[0].shareScope).toBe('host-custom'); + expect(runtimeRequire.initContainer).toHaveLength(3); + }, + }, + { + description: 'expands container init only by declared additional scopes', + build() {}, + check: async () => { + const { initContainerCalls, runtimeRequire, shareScopeMap } = + createRuntime({ + remoteShareScope: 'host-custom', + additionalInitScopes: ['secondary'], + scopeToSharingDataMapping: { + 'host-custom': [], + secondary: [], + }, + }); + const remoteEntryInitOptions = { shareScopeKeys: 'host-custom' }; + Object.defineProperty(remoteEntryInitOptions, 'shareScopeMap', { + value: shareScopeMap, + }); + + await runtimeRequire.initContainer( + shareScopeMap['host-custom'], + [], + remoteEntryInitOptions, + ); + + // The host's options reach the bundler runtime untouched (scalar + // primary binding); additional scopes are mapped separately. + expect(initContainerCalls).toHaveLength(1); + expect(initContainerCalls[0].remoteEntryInitOptions).toBe( + remoteEntryInitOptions, + ); + expect(remoteEntryInitOptions.shareScopeKeys).toBe('host-custom'); + expect(shareScopeMap.secondary).toBe( + remoteEntryInitOptions.shareScopeMap.secondary, + ); + }, + }, + { + description: 'binds the container primary scope to the host scope object', + build() {}, + check: async () => { + const { runtimeRequire, shareScopeMap: containerScopeMap } = + createRuntime({ + realInitContainerEntry: true, + containerShareScope: 'container-custom', + remoteShareScope: 'host-custom', + }); + const shareScopeMap = hostShareScopeMap(); + + await runtimeRequire.initContainer(shareScopeMap['host-custom'], [], { + shareScopeKeys: 'host-custom', + shareScopeMap, + }); + + expect(containerScopeMap['container-custom']).toBe( + shareScopeMap['host-custom'], + ); + }, + }, + { + description: 'keeps the primary binding when expanding additional scopes', + build() {}, + check: async () => { + const { + initializedScopes, + runtimeRequire, + shareScopeMap: containerScopeMap, + } = createRuntime({ + realInitContainerEntry: true, + containerShareScope: 'container-custom', + remoteShareScope: 'host-custom', + additionalInitScopes: ['secondary'], + }); + const shareScopeMap = hostShareScopeMap(); + + await runtimeRequire.initContainer(shareScopeMap['host-custom'], [], { + shareScopeKeys: 'host-custom', + shareScopeMap, + }); + + // primary alias: container name -> host object + expect(containerScopeMap['container-custom']).toBe( + shareScopeMap['host-custom'], + ); + // additional scope shares the host's object and gets initialized + expect(containerScopeMap.secondary).toBe(shareScopeMap.secondary); + expect(initializedScopes).toContain('secondary'); + // the scalar path never registers the host's name on the container + expect(containerScopeMap['host-custom']).toBeUndefined(); + }, + }, + { + description: + 'binds an additional scope by name when the host initializes it, leaving the primary pool alone', + build() {}, + check: async () => { + // Hosts initialize a container once per shared scope. Initializing the + // container's additional `layered-components` scope must bind that + // scope only; aliasing the primary `default` pool to it mixes scopes. + const { + initializedScopes, + runtimeRequire, + shareScopeMap: containerScopeMap, + } = createRuntime({ + realInitContainerEntry: true, + containerShareScope: 'default', + remoteShareScope: 'default', + additionalInitScopes: ['layered-components'], + }); + const shareScopeMap = { + default: { tag: 'host-default' }, + 'layered-components': { tag: 'host-layered' }, + }; + + await runtimeRequire.initContainer( + shareScopeMap['layered-components'], + [], + { + shareScopeKeys: 'layered-components', + shareScopeMap, + }, + ); + expect(containerScopeMap['layered-components']).toBe( + shareScopeMap['layered-components'], + ); + expect(containerScopeMap.default).not.toBe( + shareScopeMap['layered-components'], + ); + expect(initializedScopes).toContain('layered-components'); + + await runtimeRequire.initContainer(shareScopeMap.default, [], { + shareScopeKeys: 'default', + shareScopeMap, + }); + expect(containerScopeMap.default).toBe(shareScopeMap.default); + expect(containerScopeMap['layered-components']).toBe( + shareScopeMap['layered-components'], + ); + }, + }, + { + description: + 'initializes additional providers after binding an array of host scopes', + build() {}, + check: async () => { + const { + initializedScopes, + runtimeRequire, + shareScopeMap: containerScopeMap, + } = createRuntime({ + realInitContainerEntry: true, + containerShareScope: 'default', + remoteShareScope: 'default', + additionalInitScopes: ['default', 'react-layer', 'private'], + }); + const shareScopeMap = { default: {}, 'react-layer': {}, unrelated: {} }; + containerScopeMap['react-layer'] = { old: true }; + + await runtimeRequire.initContainer(shareScopeMap['react-layer'], [], { + shareScopeKeys: ['react-layer', 'default', 'unrelated'], + shareScopeMap, + }); + + expect(containerScopeMap.default).toBe(shareScopeMap.default); + expect(containerScopeMap['react-layer']).toBe( + shareScopeMap['react-layer'], + ); + expect( + initializedScopes.filter((scope) => scope === 'react-layer'), + ).toHaveLength(1); + expect(initializedScopes).not.toContain('private'); + expect(initializedScopes).not.toContain('unrelated'); + }, + }, + { + description: 'does not remap a container-owned scope listed as additional', + build() {}, + check: async () => { + // A layered provider makes ShareRuntimeModule list the container's own + // scope in additionalInitScopes; the host names that scope differently. + const { + initializedScopes, + runtimeRequire, + shareScopeMap: containerScopeMap, + } = createRuntime({ + realInitContainerEntry: true, + containerShareScope: 'container-custom', + remoteShareScope: 'host-custom', + additionalInitScopes: ['container-custom', 'secondary'], + }); + const shareScopeMap = hostShareScopeMap(); + + await runtimeRequire.initContainer(shareScopeMap['host-custom'], [], { + shareScopeKeys: 'host-custom', + shareScopeMap, + }); + + expect(containerScopeMap['container-custom']).toBe( + shareScopeMap['host-custom'], + ); + expect(shareScopeMap['container-custom']).toBeUndefined(); + expect(containerScopeMap.secondary).toBe(shareScopeMap.secondary); + expect( + initializedScopes.filter((scope) => scope === 'secondary'), + ).toHaveLength(1); + }, + }, + { + description: 'does not post-initialize scopes owned by the container', + build() {}, + check: async () => { + const { initializedScopes, runtimeRequire, shareScopeMap } = + createRuntime({ + containerShareScope: 'secondary', + additionalInitScopes: ['secondary'], + }); + const remoteEntryInitOptions = { + shareScopeKeys: 'primary', + shareScopeMap, + }; + + await runtimeRequire.initContainer( + shareScopeMap.primary, + [], + remoteEntryInitOptions, + ); + + expect(initializedScopes).toEqual([]); + }, + }, + ...['commonjs-module', 'module'].map((externalType) => ({ + description: + 'initializes a legacy %s remote once with the primary array scope'.replace( + '%s', + externalType, + ), + build() {}, + check: async () => { + const calls = []; + const legacyContainer = { + init(shareScope, initScope) { + calls.push([shareScope, initScope]); + }, + }; + const external = + externalType === 'module' + ? Promise.resolve(legacyContainer) + : legacyContainer; + const { runtimeRequire, shareScopeMap } = createRuntime({ + external, + externalType, + }); + + await runtimeRequire.I(['primary', 'secondary'], []); + + expect(calls).toHaveLength(1); + expect(calls[0]).toHaveLength(2); + expect(calls[0][0]).toBe(shareScopeMap.primary); + }, + })), + { + description: + 'matches every configured script-remote scope during version-first initialization', + build() {}, + check: async () => { + const { matchedScopes, remote, runtimeRequire } = createRuntime({ + externalType: 'script', + hasContainer: false, + }); + + await runtimeRequire.I(['primary', 'secondary'], []); + + expect(matchedScopes).toEqual(['primary', 'secondary']); + expect(remote.shareScope).toEqual(['primary', 'secondary']); + }, + }, + { + description: + 'passes the full ordered scope capability to an enhanced remote once', + build() {}, + check: async () => { + const calls = []; + const enhancedContainer = { + init(...args) { + calls.push(args); + }, + }; + const { runtimeRequire, shareScopeMap } = createRuntime({ + external: enhancedContainer, + }); + + await runtimeRequire.I(['primary', 'secondary'], []); + + expect(calls).toHaveLength(1); + expect(calls[0]).toHaveLength(3); + expect(calls[0][0]).toBe(shareScopeMap.primary); + expect(calls[0][2].shareScopeKeys).toEqual(['primary', 'secondary']); + }, + }, + { + description: 'installs scalar initial consumes synchronously', + build() {}, + check: () => { + const calls = []; + const enhancedContainer = { + init(...args) { + calls.push(args); + }, + }; + const { runtimeRequire } = createRuntime({ + external: enhancedContainer, + remoteShareScope: 'default', + consumeData: { + shareKey: 'react', + shareScope: 'default', + }, + initialConsumes: ['consume'], + }); + + // Scalar scopes keep the legacy contract: eager factories are available + // to a synchronous entry, installation is never gated, and the scope is + // initialized lazily by the consume handlers (initializing it here would + // start loading eager shares asynchronously). + expect(runtimeRequire.m.consume).toBeTypeOf('function'); + expect(runtimeRequire.federation.initialConsumesInit).toBeUndefined(); + expect(calls).toHaveLength(0); + }, + }, + { + description: + 'waits for a pending scalar scope initialization before consuming from a chunk', + build() {}, + check: async () => { + // A `module`/`promise` remote registers its shares asynchronously; a + // chunk's consumes must not resolve to the local fallback before that. + let resolveExternal; + const external = new Promise((resolve) => { + resolveExternal = resolve; + }); + const consumeCalls = []; + const { runtimeRequire } = createRuntime({ + external, + remoteShareScope: 'default', + consumeData: { + shareKey: 'react', + shareScope: 'default', + }, + chunkMapping: { chunk: ['consume'] }, + consumeCalls, + }); + + const promises = []; + runtimeRequire.f.consumes('chunk', promises); + expect(promises).toHaveLength(1); + expect(consumeCalls).toEqual([]); + resolveExternal({ init() {} }); + await Promise.all(promises); + expect(consumeCalls).toEqual(['chunk']); + }, + }, + { + description: + 'initializes ordered scopes before installing initial consumes', + build() {}, + check: async () => { + const calls = []; + const enhancedContainer = { + init(...args) { + calls.push(args); + }, + }; + const { runtimeRequire, shareScopeMap } = createRuntime({ + external: enhancedContainer, + consumeData: { + shareKey: 'react', + shareScope: ['primary', 'secondary'], + }, + initialConsumes: ['consume'], + }); + + expect(calls).toHaveLength(1); + expect(calls[0][0]).toBe(shareScopeMap.primary); + expect(calls[0][2].shareScopeKeys).toEqual(['primary', 'secondary']); + await waitForInitialConsume(runtimeRequire); + }, + }, + { + description: 'waits for ordered scopes before installing initial consumes', + build() {}, + check: async () => { + let resolveInit; + const initPromise = new Promise((resolve) => { + resolveInit = resolve; + }); + const enhancedContainer = { + init() { + return initPromise; + }, + }; + const { runtimeRequire } = createRuntime({ + external: enhancedContainer, + consumeData: { + shareKey: 'react', + shareScope: ['primary', 'secondary'], + }, + initialConsumes: ['consume'], + }); + + expect(runtimeRequire.m.consume).toBeUndefined(); + const startupPromises = []; + runtimeRequire.f.consumes('startup', startupPromises); + expect(startupPromises).toContain( + runtimeRequire.federation.initialConsumesInit, + ); + resolveInit(); + await waitForInitialConsume(runtimeRequire); + }, + }, + { + description: + 'initializes a frozen legacy container without proxying its exports', + build() {}, + check: async () => { + const calls = []; + const external = Object.freeze({ + init(...args) { + calls.push(args); + }, + }); + const { runtimeRequire, shareScopeMap } = createRuntime({ external }); + + await runtimeRequire.I(['primary', 'secondary'], []); + + expect(calls).toHaveLength(1); + expect(calls[0][0]).toBe(shareScopeMap.primary); + }, + }, + { + description: + 'does not retry a legacy container whose init throws synchronously', + build() {}, + check: async () => { + let calls = 0; + const external = { + init() { + calls += 1; + throw new Error('legacy init failed'); + }, + }; + const { runtimeRequire } = createRuntime({ external }); + + await runtimeRequire.I(['primary', 'secondary'], []); + + expect(calls).toBe(1); + }, + }, + { + description: 'selects tree-shaking fallbacks by scope and layer identity', + build() {}, + check: () => { + const sharedFallback = { + react: [ + ['legacy.js', '1.0.0', 'legacy'], + ['server.js', '1.0.0', 'server'], + ], + }; + const { runtimeRequire } = createRuntime({ + sharedFallback, + sharedFallbackVariants: { + react: [ + { + entry: 'legacy.js', + version: '1.0.0', + globalName: 'legacy', + shareScope: 'primary', + import: 'react', + }, + { + entry: 'server.js', + version: '1.0.0', + globalName: 'server', + shareScope: 'primary', + layer: 'server', + import: 'react-server', + }, + ], + }, + consumeData: { + shareKey: 'react', + shareScope: 'primary', + layer: 'server', + import: 'react-server', + }, + }); + + const fallbackKey = + runtimeRequire.federation.consumesLoadingModuleToHandlerMapping.consume + .getter; + expect(fallbackKey).toBe('react\0consume'); + expect(runtimeRequire.federation.sharedFallback[fallbackKey]).toEqual([ + ['server.js', '1.0.0', 'server'], + ]); + expect(sharedFallback.react).toHaveLength(2); + }, + }, + { + description: + 'uses the consume fallback when no tree-shaking variant matches', + build() {}, + check: () => { + const fallback = () => 'local'; + const { runtimeRequire } = createRuntime({ + sharedFallback: { react: [['client.js', '1.0.0', 'client']] }, + sharedFallbackVariants: { + react: [ + { + entry: 'client.js', + version: '1.0.0', + globalName: 'client', + shareScope: 'client', + import: 'react-client', + }, + ], + }, + consumeData: { + shareKey: 'react', + shareScope: 'server', + import: 'react-server', + fallback, + }, + }); + + expect( + runtimeRequire.federation.consumesLoadingModuleToHandlerMapping.consume + .getter, + ).toBe(fallback); + }, + }, +]; From 4c19d8191872eb23959c400a91bc6198a22e6afa Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 9 Sep 2026 07:28:32 +0000 Subject: [PATCH 20/27] fix(mf): preserve configured scalar scope initialization --- .../runtime/moduleFederationDefaultRuntime.js | 69 +++++++++------- .../module-federation-runtime.js | 78 +++++++++++++++---- 2 files changed, 100 insertions(+), 47 deletions(-) diff --git a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js index a094c3120492..7bf55582347d 100644 --- a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js +++ b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js @@ -50,15 +50,42 @@ export default function () { const additionalContainerInitScopes = runtimeRequire.initializeSharingData?.additionalInitScopes; - const createArrayScopeRequire = (shareScopes) => { - const wrapExternal = (external) => { + const createShareScopeRequire = (shareScopes) => { + const ordered = Array.isArray(shareScopes); + const wrapExternal = (external, externalModuleId) => { + if (!ordered) { + const remote = Object.values( + remotesLoadingModuleIdToRemoteDataMapping, + ).find((remote) => remote.externalModuleId === externalModuleId); + if ( + !remote || + (!Array.isArray(remote.shareScope) && + (remote.shareScope || 'default') === shareScopes) + ) { + return external; + } + } if (!external) return external; - if (external.then) return external.then(wrapExternal); + if (external.then) { + return external.then((external) => + wrapExternal(external, externalModuleId), + ); + } const init = external.init; if (typeof init !== 'function') return external; const facade = Object.create(external); Object.defineProperty(facade, 'init', { value: (shareScope, initScope, remoteEntryInitOptions) => { + if (!ordered) { + return init.call( + external, + shareScope, + initScope, + remoteEntryInitOptions === undefined + ? undefined + : withShareScopeKeys(remoteEntryInitOptions, [shareScopes]), + ); + } let initializedScopes = arrayInitializedExternals.get(external); if (!initializedScopes) { initializedScopes = new Map(); @@ -90,7 +117,7 @@ export default function () { }; return new Proxy(runtimeRequire, { apply(target, thisArg, args) { - return wrapExternal(Reflect.apply(target, thisArg, args)); + return wrapExternal(Reflect.apply(target, thisArg, args), args[0]); }, }); }; @@ -449,15 +476,12 @@ export default function () { ); }); override(runtimeRequire, 'I', (name, initScope) => { - const webpackRequire = Array.isArray(name) - ? createArrayScopeRequire(name) - : runtimeRequire; return runtimeRequire.federation.bundlerRuntime.I({ shareScopeName: name, initScope, initPromises: initializeSharingInitPromises, initTokens: initializeSharingInitTokens, - webpackRequire, + webpackRequire: createShareScopeRequire(name), }); }); // Returns `options` with `shareScopeKeys` replaced, preserving the @@ -478,19 +502,12 @@ export default function () { runtimeRequire, 'initContainer', (shareScope, initScope, remoteEntryInitOptions) => { - // A host initializes a container once per scope it shares with it. - // - Initializing one of the container's additional scopes (declared by - // ordered or layered shares) by name binds that scope only; the - // bundler runtime's array form maps by host name. Applying the - // scalar contract here would alias the container's primary scope to - // a different host pool and mix scopes. - // - Initializing the primary scope keeps the scalar contract (the - // container's primary scope is bound to the supplied host object, - // whatever either side names it) and maps the remaining additional - // scopes onto the host's pools so their shares register there. - // Scopes the container owns are bound by initContainerEntry itself. + // Scalar initialization binds the container's primary scope to the + // supplied host object, even when their names differ. The host tags + // explicit additional-scope calls with array keys to bind by name. + // Scopes the container owns are bound by initContainerEntry itself; + // map and initialize its remaining additional scopes here. const hostShareScopeMap = remoteEntryInitOptions?.shareScopeMap; - let options = remoteEntryInitOptions; const additionalScopes = []; if (additionalContainerInitScopes?.length && hostShareScopeMap) { const hostScope = remoteEntryInitOptions.shareScopeKeys || 'default'; @@ -507,17 +524,9 @@ export default function () { !containerScopes.includes(scope), ), ); - } else if ( - !containerScopes.includes(hostScope) && - additionalContainerInitScopes.includes(hostScope) - ) { - options = withShareScopeKeys(remoteEntryInitOptions, [hostScope]); - additionalScopes.push(hostScope); } else { for (const scope of additionalContainerInitScopes) { - if (scope === hostScope || containerScopes.includes(scope)) { - continue; - } + if (containerScopes.includes(scope)) continue; if (!hostShareScopeMap[scope]) hostShareScopeMap[scope] = {}; runtimeRequire.federation.instance.initShareScopeMap( scope, @@ -532,7 +541,7 @@ export default function () { runtimeRequire.federation.bundlerRuntime.initContainerEntry({ shareScope, initScope, - remoteEntryInitOptions: options, + remoteEntryInitOptions, shareScopeKey: containerShareScope, webpackRequire: runtimeRequire, }); diff --git a/tests/rspack-test/compilerCases/module-federation-runtime.js b/tests/rspack-test/compilerCases/module-federation-runtime.js index be298ce2e95f..3b1fac800dfb 100644 --- a/tests/rspack-test/compilerCases/module-federation-runtime.js +++ b/tests/rspack-test/compilerCases/module-federation-runtime.js @@ -300,6 +300,57 @@ module.exports = [ expect(containerScopeMap['host-custom']).toBeUndefined(); }, }, + { + description: + 'keeps scalar primary binding when the host name is also an additional scope', + build() {}, + check: async () => { + const { + runtimeRequire: containerRequire, + shareScopeMap: containerScopeMap, + } = createRuntime({ + realInitContainerEntry: true, + containerShareScope: 'default', + additionalInitScopes: ['host-custom'], + }); + const { runtimeRequire, shareScopeMap } = createRuntime({ + external: { init: containerRequire.initContainer }, + hasContainer: false, + remoteShareScope: 'host-custom', + }); + + await runtimeRequire.I('host-custom', []); + + expect(containerScopeMap.default).toBe(shareScopeMap['host-custom']); + expect(containerScopeMap['host-custom']).toBe( + shareScopeMap['host-custom'], + ); + }, + }, + { + description: 'binds a configured array scope by name when initialized alone', + build() {}, + check: async () => { + const { + runtimeRequire: containerRequire, + shareScopeMap: containerScopeMap, + } = createRuntime({ + realInitContainerEntry: true, + containerShareScope: 'default', + additionalInitScopes: ['primary', 'secondary'], + }); + const { runtimeRequire, shareScopeMap } = createRuntime({ + external: { init: containerRequire.initContainer }, + hasContainer: false, + remoteShareScope: ['primary', 'secondary'], + }); + + await runtimeRequire.I('secondary', []); + + expect(containerScopeMap.secondary).toBe(shareScopeMap.secondary); + expect(containerScopeMap.default).not.toBe(shareScopeMap.secondary); + }, + }, { description: 'binds an additional scope by name when the host initializes it, leaving the primary pool alone', @@ -310,7 +361,7 @@ module.exports = [ // scope only; aliasing the primary `default` pool to it mixes scopes. const { initializedScopes, - runtimeRequire, + runtimeRequire: containerRequire, shareScopeMap: containerScopeMap, } = createRuntime({ realInitContainerEntry: true, @@ -318,19 +369,15 @@ module.exports = [ remoteShareScope: 'default', additionalInitScopes: ['layered-components'], }); - const shareScopeMap = { - default: { tag: 'host-default' }, - 'layered-components': { tag: 'host-layered' }, - }; + const { runtimeRequire, shareScopeMap } = createRuntime({ + external: { init: containerRequire.initContainer }, + hasContainer: false, + remoteShareScope: 'default', + }); + shareScopeMap.default = { tag: 'host-default' }; + shareScopeMap['layered-components'] = { tag: 'host-layered' }; - await runtimeRequire.initContainer( - shareScopeMap['layered-components'], - [], - { - shareScopeKeys: 'layered-components', - shareScopeMap, - }, - ); + await runtimeRequire.I('layered-components', []); expect(containerScopeMap['layered-components']).toBe( shareScopeMap['layered-components'], ); @@ -339,10 +386,7 @@ module.exports = [ ); expect(initializedScopes).toContain('layered-components'); - await runtimeRequire.initContainer(shareScopeMap.default, [], { - shareScopeKeys: 'default', - shareScopeMap, - }); + await runtimeRequire.I('default', []); expect(containerScopeMap.default).toBe(shareScopeMap.default); expect(containerScopeMap['layered-components']).toBe( shareScopeMap['layered-components'], From a7c7947218ad7bd42d31e7c25a2efcf898b4cc6d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 10 Sep 2026 23:01:05 +0000 Subject: [PATCH 21/27] fix(mf): initialize shared scopes for fallback remotes --- .../src/container/remote_runtime_module.rs | 60 ++++++++++++------- .../runtime/moduleFederationDefaultRuntime.js | 4 +- .../remote-fallback-share-scope/index.js | 5 ++ .../rspack.config.js | 27 +++++++++ 4 files changed, 73 insertions(+), 23 deletions(-) create mode 100644 tests/rspack-test/configCases/container-1-5/remote-fallback-share-scope/index.js create mode 100644 tests/rspack-test/configCases/container-1-5/remote-fallback-share-scope/rspack.config.js diff --git a/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs b/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs index 48f0507089fe..2e5428f9c8bb 100644 --- a/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs +++ b/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs @@ -10,7 +10,7 @@ use rspack_plugin_runtime::extract_runtime_globals_from_ejs; use rustc_hash::FxHashMap; use serde::Serialize; -use super::remote_module::RemoteModule; +use super::{fallback_module::FallbackModule, remote_module::RemoteModule}; use crate::{ ShareScope, utils::{runtime_require_scope_name, runtime_require_scope_requirement}, @@ -105,26 +105,28 @@ impl RuntimeModule for RemoteRuntimeModule { let external_module = module_graph .get_module_by_dependency_id(dep) .expect("should have module"); - let remote_info = if self.enhanced { + let remote_infos = if self.enhanced { if let Some(external_module) = external_module.downcast_ref::() { - let external_type = external_module.get_external_type().as_str(); - let name = if external_type == "script" { - match extract_url_and_global(external_module.get_request().primary()) { - Ok(url_and_global) => url_and_global.global, - Err(_) => "", - } - } else { - "" - }; - Some(RemoteInfo { - external_type, - name, - }) + vec![RemoteInfo::from(external_module)] + } else if let Some(fallback_module) = external_module.downcast_ref::() { + fallback_module + .get_dependency_ids() + .map(|dep| { + module_graph + .get_module_by_dependency_id(dep) + .and_then(|module| module.downcast_ref::()) + .map(RemoteInfo::from) + .unwrap_or(RemoteInfo { + external_type: "", + name: "", + }) + }) + .collect() } else { - None + vec![] } } else { - None + vec![] }; let external_module_id = ChunkGraph::get_module_id( &compilation.module_ids_artifact, @@ -139,7 +141,7 @@ impl RuntimeModule for RemoteRuntimeModule { name, external_module_id, remote_name: &m.remote_key, - remote_info, + remote_infos, }, ); } @@ -189,8 +191,8 @@ struct RemoteData<'a> { name: &'a str, external_module_id: &'a ModuleId, remote_name: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - remote_info: Option>, + #[serde(skip_serializing_if = "Vec::is_empty")] + remote_infos: Vec>, } #[derive(Serialize)] @@ -200,6 +202,24 @@ struct RemoteInfo<'a> { name: &'a str, } +impl<'a> From<&'a ExternalModule> for RemoteInfo<'a> { + fn from(external_module: &'a ExternalModule) -> Self { + let external_type = external_module.get_external_type().as_str(); + let name = if external_type == "script" { + match extract_url_and_global(external_module.get_request().primary()) { + Ok(url_and_global) => url_and_global.global, + Err(_) => "", + } + } else { + "" + }; + Self { + external_type, + name, + } + } +} + #[derive(Serialize)] #[serde(untagged)] enum ShareScopeField<'a> { diff --git a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js index 7bf55582347d..2aa715bf631b 100644 --- a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js +++ b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js @@ -389,9 +389,7 @@ export default function () { const info = existingInfos.length > 0 ? existingInfos - : remoteData.remoteInfo - ? [remoteData.remoteInfo] - : []; + : remoteData.remoteInfos || []; if (info.length > 0) idToRemoteMap[id] = info; } return idToRemoteMap; diff --git a/tests/rspack-test/configCases/container-1-5/remote-fallback-share-scope/index.js b/tests/rspack-test/configCases/container-1-5/remote-fallback-share-scope/index.js new file mode 100644 index 000000000000..e9d9f58ef700 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/remote-fallback-share-scope/index.js @@ -0,0 +1,5 @@ +it('should initialize a fallback remote with each configured share scope', async () => { + const shareScopeKeys = await import('remote/value'); + + expect(shareScopeKeys.default).toEqual(['scope1', 'scope2']); +}); diff --git a/tests/rspack-test/configCases/container-1-5/remote-fallback-share-scope/rspack.config.js b/tests/rspack-test/configCases/container-1-5/remote-fallback-share-scope/rspack.config.js new file mode 100644 index 000000000000..b6e8b5e9d6c5 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/remote-fallback-share-scope/rspack.config.js @@ -0,0 +1,27 @@ +const { ContainerReferencePlugin, ModuleFederationPlugin } = + require('@rspack/core').container; + +module.exports = { + target: 'async-node', + plugins: [ + new ModuleFederationPlugin({ name: 'host' }), + new ContainerReferencePlugin({ + remoteType: 'promise', + enhanced: true, + remotes: { + remote: { + external: [ + 'Promise.reject(new Error("unavailable"))', + `Promise.resolve({ + init: (_shareScope, _initScope, options) => { + globalThis.__shareScopeKeys = options.shareScopeKeys; + }, + get: () => Promise.resolve(() => globalThis.__shareScopeKeys) + })`, + ], + shareScope: ['scope1', 'scope2'], + }, + }, + }), + ], +}; From d178241d7fe11b3017d1711a0b5514d8f75cf2f6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 11 Sep 2026 02:02:26 +0000 Subject: [PATCH 22/27] fix(mf): satisfy fallback metadata clippy check --- .../src/container/remote_runtime_module.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs b/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs index 2e5428f9c8bb..7cf0e98227a0 100644 --- a/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs +++ b/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs @@ -115,11 +115,13 @@ impl RuntimeModule for RemoteRuntimeModule { module_graph .get_module_by_dependency_id(dep) .and_then(|module| module.downcast_ref::()) - .map(RemoteInfo::from) - .unwrap_or(RemoteInfo { - external_type: "", - name: "", - }) + .map_or( + RemoteInfo { + external_type: "", + name: "", + }, + RemoteInfo::from, + ) }) .collect() } else { From a8abb0949f85fe1e239af7e7f9f0a06d33e3d622 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 23:51:41 +0000 Subject: [PATCH 23/27] refactor(mf): reuse json_stringify in the remote runtime module Also drop an #[inline(always)] on the shared exports optimizer that a later PR in the stack removed anyway. --- .../rspack_plugin_mf/src/container/remote_runtime_module.rs | 6 +++--- .../src/sharing/shared_used_exports_optimizer_plugin.rs | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs b/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs index 7cf0e98227a0..ff14190b3449 100644 --- a/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs +++ b/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs @@ -13,7 +13,7 @@ use serde::Serialize; use super::{fallback_module::FallbackModule, remote_module::RemoteModule}; use crate::{ ShareScope, - utils::{runtime_require_scope_name, runtime_require_scope_requirement}, + utils::{json_stringify, runtime_require_scope_name, runtime_require_scope_requirement}, }; static REMOTES_LOADING_TEMPLATE: &str = include_str!("./remotesLoading.ejs"); @@ -178,9 +178,9 @@ impl RuntimeModule for RemoteRuntimeModule { "#, require_name = runtime_require_scope_name(runtime_template), chunk_mapping = - simd_json::to_string(&chunk_to_remotes_mapping).expect("valid remote chunk mapping"), + json_stringify(&chunk_to_remotes_mapping), id_to_remote_data_mapping = - simd_json::to_string(&id_to_remote_data_mapping).expect("valid remote data"), + json_stringify(&id_to_remote_data_mapping), remotes_loading_impl = remotes_loading_impl, )) } diff --git a/crates/rspack_plugin_mf/src/sharing/shared_used_exports_optimizer_plugin.rs b/crates/rspack_plugin_mf/src/sharing/shared_used_exports_optimizer_plugin.rs index fe955fecc0f9..37cc69f134a1 100644 --- a/crates/rspack_plugin_mf/src/sharing/shared_used_exports_optimizer_plugin.rs +++ b/crates/rspack_plugin_mf/src/sharing/shared_used_exports_optimizer_plugin.rs @@ -25,7 +25,6 @@ use super::{ }; use crate::{ShareScope, SharedIdentity, container::container_entry_module::ContainerEntryModule}; -#[inline(always)] fn referenced_exports_for_output( shared_referenced_exports: &FxHashMap>, share_key: &str, From 6d63ed3b9c2d3a44d399b9699708de6aa25b43df Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 16 Sep 2026 01:43:16 +0000 Subject: [PATCH 24/27] chore(mf): drop the unused validateShareScope, a stale experiments.outputModule, and document ordered-scope startup --- packages/rspack/src/sharing/SharePlugin.ts | 8 -------- .../ordered-consume-module-container/rspack.config.js | 1 - website/docs/en/plugins/module-federation-plugin.mdx | 2 +- website/docs/zh/plugins/module-federation-plugin.mdx | 2 +- 4 files changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/rspack/src/sharing/SharePlugin.ts b/packages/rspack/src/sharing/SharePlugin.ts index 6358afbfc0b7..41b342c27e9a 100644 --- a/packages/rspack/src/sharing/SharePlugin.ts +++ b/packages/rspack/src/sharing/SharePlugin.ts @@ -30,14 +30,6 @@ export function normalizeShareScope( return shareScope; } -export function validateShareScope( - shareScope: ShareScope, - enhanced: boolean, - pluginName: string, -): void { - normalizeShareScope(shareScope, enhanced, pluginName); -} - export type SharePluginOptions = { shareScope?: ShareScope; shared: Shared; diff --git a/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/rspack.config.js b/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/rspack.config.js index 5553332f9712..a484f4b4f06a 100644 --- a/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/rspack.config.js +++ b/tests/rspack-test/configCases/container-1-5/ordered-consume-module-container/rspack.config.js @@ -10,7 +10,6 @@ module.exports = (_, { testPath }) => name === 'initial' ? { index: './index.js', ordered: './ordered.js' } : { index: './index.js' }, - experiments: { outputModule: true }, output: { path: path.join(testPath, name), filename: '[name].mjs', diff --git a/website/docs/en/plugins/module-federation-plugin.mdx b/website/docs/en/plugins/module-federation-plugin.mdx index 24f94dac224d..140cbe6d2b54 100644 --- a/website/docs/en/plugins/module-federation-plugin.mdx +++ b/website/docs/en/plugins/module-federation-plugin.mdx @@ -83,7 +83,7 @@ Define the output format of remote container entry. The default libraryType is " Define the namespace for shared dependencies in the current container. By configuring share scopes between different containers, the sharing behavior of modules can be controlled, including determining which modules are shared between different containers. The default share scope is `"default"`. -An array preserves scope order. For example, `['custom', 'default']` uses `"custom"` as the primary scope when initializing a legacy container. Enhanced containers receive the complete ordered scope list. The scopes keep separate shared registries. +An array preserves scope order. For example, `['custom', 'default']` uses `"custom"` as the primary scope when initializing a legacy container. Enhanced containers receive the complete ordered scope list. The scopes keep separate shared registries. An entry whose initial chunks consume a shared module from an ordered scope array starts asynchronously, since the scopes are initialized before the consume is installed; module-library output awaits that initialization. An array must contain at least one scope. Multiple scopes require the enhanced runtime used by `ModuleFederationPlugin`, or `enhanced: true` when using the lower-level federation plugins. Without the enhanced runtime, a single-element array is treated as a string and arrays with multiple entries are rejected. These rules also apply to `remotes.*.shareScope` and `shared.*.shareScope`. diff --git a/website/docs/zh/plugins/module-federation-plugin.mdx b/website/docs/zh/plugins/module-federation-plugin.mdx index 37ef4c041653..37e23588edf6 100644 --- a/website/docs/zh/plugins/module-federation-plugin.mdx +++ b/website/docs/zh/plugins/module-federation-plugin.mdx @@ -83,7 +83,7 @@ export default { 定义当前应用共享依赖的命名空间。通过在不同的应用之间配置命名空间,可以控制模块的共享行为,包括确定哪些模块在不同的应用之间是共享的。默认的命名空间为 `"default"`。 -数组会保留共享作用域的顺序。例如,`['custom', 'default']` 在初始化旧版容器时使用 `"custom"` 作为主作用域。增强容器会接收完整的有序作用域列表,各作用域保留独立的共享模块注册表。 +数组会保留共享作用域的顺序。例如,`['custom', 'default']` 在初始化旧版容器时使用 `"custom"` 作为主作用域。增强容器会接收完整的有序作用域列表,各作用域保留独立的共享模块注册表。若入口的初始 chunk 消费了有序作用域数组中的共享模块,该入口会异步启动,因为作用域会先于消费模块安装完成初始化;module 类型的库输出会等待该初始化完成。 数组必须至少包含一个作用域。多个作用域需要 `ModuleFederationPlugin` 使用的增强运行时,或在使用底层联邦插件时设置 `enhanced: true`。不使用增强运行时时,单元素数组会被视为字符串,包含多个元素的数组会被拒绝。这些规则也适用于 `remotes.*.shareScope` 和 `shared.*.shareScope`。 From f9cea9841104c17cd3acfc0b127991d7665cbc5e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 22 Sep 2026 22:45:44 +0000 Subject: [PATCH 25/27] style(mf): format remote runtime mapping arguments --- .../rspack_plugin_mf/src/container/remote_runtime_module.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs b/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs index ff14190b3449..8752c1facccf 100644 --- a/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs +++ b/crates/rspack_plugin_mf/src/container/remote_runtime_module.rs @@ -177,10 +177,8 @@ impl RuntimeModule for RemoteRuntimeModule { {remotes_loading_impl} "#, require_name = runtime_require_scope_name(runtime_template), - chunk_mapping = - json_stringify(&chunk_to_remotes_mapping), - id_to_remote_data_mapping = - json_stringify(&id_to_remote_data_mapping), + chunk_mapping = json_stringify(&chunk_to_remotes_mapping), + id_to_remote_data_mapping = json_stringify(&id_to_remote_data_mapping), remotes_loading_impl = remotes_loading_impl, )) } From 0fe49a41a0b86355fc90eff67698728a8f636930 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:26:28 -0700 Subject: [PATCH 26/27] fix(mf): isolate remote scope initialization and alias contracts --- .../runtime/moduleFederationDefaultRuntime.js | 104 +++++++++----- .../module-federation-runtime.js | 131 ++++++++++++++++-- 2 files changed, 194 insertions(+), 41 deletions(-) diff --git a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js index 2aa715bf631b..cfd1f5e6ab5d 100644 --- a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js +++ b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js @@ -52,11 +52,29 @@ export default function () { const createShareScopeRequire = (shareScopes) => { const ordered = Array.isArray(shareScopes); - const wrapExternal = (external, externalModuleId) => { + // The bundler runtime reads each remote's external request from this + // mapping. Give each entry its own token so aliases sharing a deduplicated + // external module retain their individual scope contracts. + const remotes = runtimeRequire.federation.bundlerRuntimeOptions.remotes; + const externalRequests = new Map(); + const idToExternalAndNameMapping = {}; + for (const [id, data] of Object.entries( + remotes.idToExternalAndNameMapping, + )) { + const request = {}; + externalRequests.set(request, { id: data[2], shareScope: data[0] }); + idToExternalAndNameMapping[id] = [...data]; + idToExternalAndNameMapping[id][2] = request; + } + const federation = { + ...runtimeRequire.federation, + bundlerRuntimeOptions: { + ...runtimeRequire.federation.bundlerRuntimeOptions, + remotes: { ...remotes, idToExternalAndNameMapping }, + }, + }; + const wrapExternal = (external, remote) => { if (!ordered) { - const remote = Object.values( - remotesLoadingModuleIdToRemoteDataMapping, - ).find((remote) => remote.externalModuleId === externalModuleId); if ( !remote || (!Array.isArray(remote.shareScope) && @@ -67,9 +85,7 @@ export default function () { } if (!external) return external; if (external.then) { - return external.then((external) => - wrapExternal(external, externalModuleId), - ); + return external.then((external) => wrapExternal(external, remote)); } const init = external.init; if (typeof init !== 'function') return external; @@ -77,14 +93,14 @@ export default function () { Object.defineProperty(facade, 'init', { value: (shareScope, initScope, remoteEntryInitOptions) => { if (!ordered) { - return init.call( - external, - shareScope, - initScope, - remoteEntryInitOptions === undefined - ? undefined - : withShareScopeKeys(remoteEntryInitOptions, [shareScopes]), - ); + return remoteEntryInitOptions === undefined + ? init.call(external, shareScope, initScope) + : init.call( + external, + shareScope, + initScope, + withShareScopeKeys(remoteEntryInitOptions, [shareScopes]), + ); } let initializedScopes = arrayInitializedExternals.get(external); if (!initializedScopes) { @@ -116,8 +132,18 @@ export default function () { return facade; }; return new Proxy(runtimeRequire, { + get(target, key, receiver) { + return key === 'federation' + ? federation + : Reflect.get(target, key, receiver); + }, apply(target, thisArg, args) { - return wrapExternal(Reflect.apply(target, thisArg, args), args[0]); + const remote = externalRequests.get(args[0]); + const moduleArgs = remote ? [remote.id, ...args.slice(1)] : args; + return wrapExternal( + Reflect.apply(target, thisArg, moduleArgs), + remote, + ); }, }); }; @@ -132,23 +158,35 @@ export default function () { return; } const arrayAwareInitializeSharing = function (shareScope, options) { - const arrayRemotes = []; - for (const remote of instance.options.remotes) { - if ( - Array.isArray(remote.shareScope) && - remote.shareScope.includes(shareScope) - ) { - arrayRemotes.push([remote, remote.shareScope]); - remote.shareScope = shareScope; - } - } - try { - return initializeSharing.call(this, shareScope, options); - } finally { - for (const [remote, shareScopes] of arrayRemotes) { - remote.shareScope = shareScopes; - } - } + const host = this.host || instance; + const remotes = host.options.remotes.map((remote) => + Array.isArray(remote.shareScope) && + remote.shareScope.includes(shareScope) + ? Object.create(remote, { + shareScope: { value: shareScope, enumerable: true }, + }) + : remote, + ); + // A call-local view survives asynchronous hooks and overlapping scope + // initialization without temporarily changing the host's remote config. + const scopedOptions = Object.create(host.options, { + remotes: { value: remotes }, + }); + const scopedHost = new Proxy(host, { + get(target, key, receiver) { + return key === 'options' + ? scopedOptions + : Reflect.get(target, key, receiver); + }, + }); + const scopedHandler = new Proxy(this, { + get(target, key, receiver) { + return key === 'host' + ? scopedHost + : Reflect.get(target, key, receiver); + }, + }); + return initializeSharing.call(scopedHandler, shareScope, options); }; arrayAwareInitializeSharing.__rspack_share_scope_array_wrapper__ = true; sharedHandler.initializeSharing = arrayAwareInitializeSharing; diff --git a/tests/rspack-test/compilerCases/module-federation-runtime.js b/tests/rspack-test/compilerCases/module-federation-runtime.js index 3b1fac800dfb..ab814de7a607 100644 --- a/tests/rspack-test/compilerCases/module-federation-runtime.js +++ b/tests/rspack-test/compilerCases/module-federation-runtime.js @@ -30,6 +30,10 @@ function createRuntime({ consumeCalls = [], additionalInitScopes = [], scopeToSharingDataMapping = {}, + deferRemoteScan = false, + rejectRemoteScan = false, + remoteData, + legacyInitialization = false, // Use the real bundler-runtime initContainerEntry instead of recording // its arguments; the runtime under test then gets its own scope map. realInitContainerEntry = false, @@ -55,7 +59,7 @@ function createRuntime({ }, remotesLoadingData: { chunkMapping: {}, - moduleIdToRemoteDataMapping: { + moduleIdToRemoteDataMapping: remoteData || { remote: { shareScope: remoteShareScope, name: './module', @@ -95,13 +99,19 @@ function createRuntime({ sharedHandler: { initializeSharing(shareScope) { initializedScopes.push(shareScope); - if ( - instance.options.remotes.some( - (item) => item.shareScope === shareScope, + const scan = () => { + this.completedScans = (this.completedScans || 0) + 1; + this.host.completedScans = (this.host.completedScans || 0) + 1; + if (rejectRemoteScan) throw new Error('remote scan failed'); + if ( + this.host.options.remotes.some( + (item) => item.shareScope === shareScope, + ) ) - ) { - matchedScopes.push(shareScope); - } + matchedScopes.push(shareScope); + }; + if (deferRemoteScan) return Promise.resolve().then(scan); + scan(); return []; }, }, @@ -114,6 +124,7 @@ function createRuntime({ }, registerShared() {}, }; + instance.sharedHandler.host = instance; const localBundlerRuntime = { ...bundlerRuntime, consumes: (options) => { @@ -127,6 +138,15 @@ function createRuntime({ initContainerCalls.push(options); }, }; + if (legacyInitialization) { + localBundlerRuntime.I = ({ webpackRequire, shareScopeName, initScope }) => { + const remoteId = + webpackRequire.federation.bundlerRuntimeOptions.remotes + .idToExternalAndNameMapping.remote[2]; + const container = webpackRequire(remoteId); + return container.init(webpackRequire.S[shareScopeName], initScope); + }; + } const importedBundlerRuntime = { ...localBundlerRuntime, bundlerRuntime: localBundlerRuntime, @@ -183,6 +203,100 @@ const hostShareScopeMap = () => ({ /** @type {import('@rspack/test-tools').TCompilerCaseConfig[]} */ module.exports = [ + { + description: + 'keeps concurrent asynchronous remote scans scoped without mutating remotes', + build() {}, + check: async () => { + const { instance, matchedScopes, remote } = createRuntime({ + deferRemoteScan: true, + }); + const first = instance.initializeSharing('primary'); + const second = instance.initializeSharing('secondary'); + expect(remote.shareScope).toEqual(['primary', 'secondary']); + await Promise.all([first, second]); + expect(matchedScopes).toEqual(['primary', 'secondary']); + expect(remote.shareScope).toEqual(['primary', 'secondary']); + expect(instance.completedScans).toBe(2); + expect(instance.sharedHandler.completedScans).toBe(2); + }, + }, + { + description: + 'preserves remote configuration after a rejected asynchronous scan', + build() {}, + check: async () => { + const { instance, remote } = createRuntime({ + deferRemoteScan: true, + rejectRemoteScan: true, + }); + const result = await Promise.allSettled([ + instance.initializeSharing('primary'), + ]); + expect(result[0].status).toBe('rejected'); + expect(result[0].reason.message).toBe('remote scan failed'); + expect(remote.shareScope).toEqual(['primary', 'secondary']); + expect(instance.completedScans).toBe(1); + expect(instance.sharedHandler.completedScans).toBe(1); + }, + }, + { + description: 'keeps scalar legacy initialization at two arguments', + build() {}, + check: async () => { + const calls = []; + const { runtimeRequire } = createRuntime({ + legacyInitialization: true, + external: { + init(...args) { + calls.push(args); + }, + }, + }); + await runtimeRequire.I('primary', []); + expect(calls).toHaveLength(1); + expect(calls[0]).toHaveLength(2); + }, + }, + ...[false, true].map((reverse) => ({ + description: `keeps shared-external aliases' scope contracts distinct (${reverse})`, + build() {}, + check: async () => { + const contracts = []; + const entries = [ + [ + 'scalar', + { + shareScope: 'primary', + name: './scalar', + externalModuleId: 'external', + remoteName: 'remote', + }, + ], + [ + 'ordered', + { + shareScope: ['primary', 'secondary'], + name: './ordered', + externalModuleId: 'external', + remoteName: 'remote', + }, + ], + ]; + const { runtimeRequire } = createRuntime({ + external: { + init(_scope, _initScope, options) { + contracts.push(options.shareScopeKeys); + }, + }, + remoteData: Object.fromEntries(reverse ? entries.reverse() : entries), + }); + await runtimeRequire.I('primary', []); + expect(contracts).toEqual( + reverse ? [['primary'], 'primary'] : ['primary', ['primary']], + ); + }, + })), { description: 'preserves scalar custom-scope container initialization', build() {}, @@ -328,7 +442,8 @@ module.exports = [ }, }, { - description: 'binds a configured array scope by name when initialized alone', + description: + 'binds a configured array scope by name when initialized alone', build() {}, check: async () => { const { From ad3577d3e75049ec56ab1eaccc7ddec2bf6566bd Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:43:57 -0700 Subject: [PATCH 27/27] fix(mf): keep serialized runtime self-contained and preserve scalar fast paths --- .../runtime/moduleFederationDefaultRuntime.js | 37 +++++++++++++++---- .../module-federation-runtime.js | 29 ++++++++++++++- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js index cfd1f5e6ab5d..803a1e66b6a5 100644 --- a/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js +++ b/packages/rspack/src/runtime/moduleFederationDefaultRuntime.js @@ -52,6 +52,16 @@ export default function () { const createShareScopeRequire = (shareScopes) => { const ordered = Array.isArray(shareScopes); + if ( + !ordered && + Object.values(remotesLoadingModuleIdToRemoteDataMapping).every( + (remote) => + !Array.isArray(remote.shareScope) && + (remote.shareScope || 'default') === shareScopes, + ) + ) { + return runtimeRequire; + } // The bundler runtime reads each remote's external request from this // mapping. Give each entry its own token so aliases sharing a deduplicated // external module retain their individual scope contracts. @@ -66,13 +76,17 @@ export default function () { idToExternalAndNameMapping[id] = [...data]; idToExternalAndNameMapping[id][2] = request; } - const federation = { - ...runtimeRequire.federation, - bundlerRuntimeOptions: { - ...runtimeRequire.federation.bundlerRuntimeOptions, - remotes: { ...remotes, idToExternalAndNameMapping }, - }, - }; + // This function is serialized without its module scope. Avoid object + // spread, which the package build lowers to out-of-function helpers. + const federation = Object.assign({}, runtimeRequire.federation, { + bundlerRuntimeOptions: Object.assign( + {}, + runtimeRequire.federation.bundlerRuntimeOptions, + { + remotes: Object.assign({}, remotes, { idToExternalAndNameMapping }), + }, + ), + }); const wrapExternal = (external, remote) => { if (!ordered) { if ( @@ -159,6 +173,15 @@ export default function () { } const arrayAwareInitializeSharing = function (shareScope, options) { const host = this.host || instance; + if ( + !host.options.remotes.some( + (remote) => + Array.isArray(remote.shareScope) && + remote.shareScope.includes(shareScope), + ) + ) { + return initializeSharing.call(this, shareScope, options); + } const remotes = host.options.remotes.map((remote) => Array.isArray(remote.shareScope) && remote.shareScope.includes(shareScope) diff --git a/tests/rspack-test/compilerCases/module-federation-runtime.js b/tests/rspack-test/compilerCases/module-federation-runtime.js index ab814de7a607..79a377efc65a 100644 --- a/tests/rspack-test/compilerCases/module-federation-runtime.js +++ b/tests/rspack-test/compilerCases/module-federation-runtime.js @@ -1,5 +1,5 @@ const runtime = - require('../../../packages/rspack/src/runtime/moduleFederationDefaultRuntime.js').default; + require('../../../packages/rspack/dist/moduleFederationDefaultRuntime.js').default; const { bundlerRuntime, } = require('@module-federation/runtime-tools/webpack-bundler-runtime'); @@ -34,6 +34,8 @@ function createRuntime({ rejectRemoteScan = false, remoteData, legacyInitialization = false, + initializationRequires = [], + sharingHandlers = [], // Use the real bundler-runtime initContainerEntry instead of recording // its arguments; the runtime under test then gets its own scope map. realInitContainerEntry = false, @@ -98,6 +100,7 @@ function createRuntime({ shareScopeMap, sharedHandler: { initializeSharing(shareScope) { + sharingHandlers.push(this); initializedScopes.push(shareScope); const scan = () => { this.completedScans = (this.completedScans || 0) + 1; @@ -127,6 +130,10 @@ function createRuntime({ instance.sharedHandler.host = instance; const localBundlerRuntime = { ...bundlerRuntime, + I: (options) => { + initializationRequires.push(options.webpackRequire); + return bundlerRuntime.I(options); + }, consumes: (options) => { consumeCalls.push(options.chunkId); }, @@ -203,6 +210,26 @@ const hostShareScopeMap = () => ({ /** @type {import('@rspack/test-tools').TCompilerCaseConfig[]} */ module.exports = [ + { + description: + 'leaves ordinary scalar sharing on the original runtime and handler', + build() {}, + check: async () => { + const initializationRequires = []; + const sharingHandlers = []; + const { runtimeRequire, instance } = createRuntime({ + remoteShareScope: 'default', + external: { init() {} }, + initializationRequires, + sharingHandlers, + }); + await runtimeRequire.I('default', []); + expect(initializationRequires).toHaveLength(1); + expect(initializationRequires[0]).toBe(runtimeRequire); + expect(sharingHandlers).toHaveLength(1); + expect(sharingHandlers[0]).toBe(instance.sharedHandler); + }, + }, { description: 'keeps concurrent asynchronous remote scans scoped without mutating remotes',