From 044311aca51deb8f33393fef65040ff022fc9777 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Mon, 25 May 2026 18:48:35 +0200 Subject: [PATCH] fix: lazy load DTS plugin modules --- packages/manifest/__tests__/utils.spec.ts | 107 ++++++++++++++++ packages/manifest/src/utils.ts | 30 ++++- .../__tests__/ModuleFederationPlugin.spec.ts | 114 ++++++++++++++++-- packages/rspack/src/ModuleFederationPlugin.ts | 16 ++- 4 files changed, 255 insertions(+), 12 deletions(-) create mode 100644 packages/manifest/__tests__/utils.spec.ts diff --git a/packages/manifest/__tests__/utils.spec.ts b/packages/manifest/__tests__/utils.spec.ts new file mode 100644 index 00000000000..f808ca49421 --- /dev/null +++ b/packages/manifest/__tests__/utils.spec.ts @@ -0,0 +1,107 @@ +describe('getTypesMetaInfo', () => { + const defaultTypesMetaInfo = { + path: '', + name: '', + zip: '', + api: '', + }; + + beforeEach(() => { + jest.resetModules(); + }); + + it('does not load the DTS core when DTS is disabled', async () => { + const dtsCoreFactory = jest.fn(() => { + throw new Error('DTS core should not be loaded'); + }); + + jest.doMock('@module-federation/dts-plugin/core', dtsCoreFactory, { + virtual: true, + }); + jest.doMock( + '@module-federation/sdk', + () => ({ + normalizeOptions: jest.fn(() => { + throw new Error('normalizeOptions should not be called'); + }), + }), + { virtual: true }, + ); + jest.doMock('../src/logger', () => ({ + __esModule: true, + default: { + warn: jest.fn(), + }, + })); + + const { getTypesMetaInfo } = await import('../src/utils'); + + expect(getTypesMetaInfo({ dts: false }, process.cwd())).toEqual( + defaultTypesMetaInfo, + ); + expect(dtsCoreFactory).not.toHaveBeenCalled(); + }); + + it('loads the DTS core when DTS metadata is enabled', async () => { + const normalizeOptions = jest.fn( + (_enable: boolean, defaultOptions: Record | boolean) => + (options: unknown) => { + if (options === false) { + return false; + } + + if (options && typeof options === 'object') { + return { + ...(typeof defaultOptions === 'object' ? defaultOptions : {}), + ...options, + }; + } + + return defaultOptions; + }, + ); + const isTSProject = jest.fn(() => true); + const retrieveTypesAssetsInfo = jest.fn(() => ({ + apiFileName: 'api-types.d.ts', + zipName: 'types.zip', + })); + + jest.doMock( + '@module-federation/dts-plugin/core', + () => ({ + isTSProject, + retrieveTypesAssetsInfo, + }), + { virtual: true }, + ); + jest.doMock( + '@module-federation/sdk', + () => ({ + normalizeOptions, + }), + { virtual: true }, + ); + jest.doMock('../src/logger', () => ({ + __esModule: true, + default: { + warn: jest.fn(), + }, + })); + + const { getTypesMetaInfo } = await import('../src/utils'); + + expect(getTypesMetaInfo({ dts: true }, '/workspace/app')).toEqual({ + path: '', + name: '', + zip: 'types.zip', + api: 'api-types.d.ts', + }); + expect(isTSProject).toHaveBeenCalledWith(true, '/workspace/app'); + expect(retrieveTypesAssetsInfo).toHaveBeenCalledWith( + expect.objectContaining({ + context: '/workspace/app', + moduleFederationConfig: { dts: true }, + }), + ); + }); +}); diff --git a/packages/manifest/src/utils.ts b/packages/manifest/src/utils.ts index ddd5d05abce..0338d3999d2 100644 --- a/packages/manifest/src/utils.ts +++ b/packages/manifest/src/utils.ts @@ -5,6 +5,7 @@ import type { } from 'webpack/lib/stats/DefaultStatsFactoryPlugin'; import path from 'path'; import fs from 'fs'; +import { createRequire } from 'node:module'; import { StatsAssets, moduleFederationPlugin, @@ -14,13 +15,28 @@ import { normalizeOptions, MetaDataTypes, } from '@module-federation/sdk'; -import { - isTSProject, - retrieveTypesAssetsInfo, -} from '@module-federation/dts-plugin/core'; import { HOT_UPDATE_SUFFIX, PLUGIN_IDENTIFIER } from './constants'; import logger from './logger'; +// Rslib rewrites import.meta.url for both CJS and ESM outputs. +// @ts-ignore +const nodeRequire = createRequire(import.meta.url); +type DtsCoreModule = { + isTSProject: ( + dtsOptions: moduleFederationPlugin.ModuleFederationPluginOptions['dts'], + context: string, + ) => boolean; + retrieveTypesAssetsInfo: ( + options: moduleFederationPlugin.DtsRemoteOptions & { + context: string; + moduleFederationConfig: moduleFederationPlugin.ModuleFederationPluginOptions; + }, + ) => { + apiFileName: string; + zipName: string; + }; +}; + function isHotFile(file: string) { return file.includes(HOT_UPDATE_SUFFIX); } @@ -238,7 +254,13 @@ export function getTypesMetaInfo( zip: '', api: '', }; + if (pluginOptions.dts === false) { + return defaultTypesMetaInfo; + } try { + const { isTSProject, retrieveTypesAssetsInfo } = nodeRequire( + '@module-federation/dts-plugin/core', + ) as DtsCoreModule; const normalizedDtsOptions = normalizeOptions( isTSProject(pluginOptions.dts, context), diff --git a/packages/rspack/__tests__/ModuleFederationPlugin.spec.ts b/packages/rspack/__tests__/ModuleFederationPlugin.spec.ts index f8e9cfa355e..944ae080ad6 100644 --- a/packages/rspack/__tests__/ModuleFederationPlugin.spec.ts +++ b/packages/rspack/__tests__/ModuleFederationPlugin.spec.ts @@ -1,10 +1,90 @@ -import { - resolveRspackRuntimeAlias, - resolveRspackRuntimeImplementation, -} from '../src/ModuleFederationPlugin'; +function mockModuleFederationPluginDependencies( + dtsPluginFactory = () => ({ + DtsPlugin: class { + apply = jest.fn(); + addRuntimePlugins = jest.fn(); + }, + }), +) { + const noopLogger = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + }; + + jest.doMock('@module-federation/dts-plugin', dtsPluginFactory, { + virtual: true, + }); + jest.doMock( + '@module-federation/sdk', + () => ({ + bindLoggerToCompiler: jest.fn(), + composeKeyWithSeparator: (...parts: string[]) => parts.join(':'), + createInfrastructureLogger: jest.fn(() => noopLogger), + createLogger: jest.fn(() => noopLogger), + moduleFederationPlugin: {}, + }), + { virtual: true }, + ); + jest.doMock( + '@module-federation/manifest', + () => ({ + StatsPlugin: class { + apply = jest.fn(); + }, + }), + { virtual: true }, + ); + jest.doMock( + '@module-federation/managers', + () => ({ + ContainerManager: class { + init = jest.fn(); + + get enable() { + return false; + } + + get containerPluginExposesOptions() { + return {}; + } + }, + utils: { + getBuildVersion: () => 'test-build', + }, + }), + { virtual: true }, + ); + jest.doMock( + '@module-federation/bridge-react-webpack-plugin', + () => ({ + __esModule: true, + default: jest.fn(), + }), + { virtual: true }, + ); + jest.doMock('../src/RemoteEntryPlugin', () => ({ + RemoteEntryPlugin: class { + apply = jest.fn(); + }, + })); +} + +async function importModuleFederationPlugin() { + mockModuleFederationPluginDependencies(); + + return import('../src/ModuleFederationPlugin'); +} describe('runtime resolution compatibility', () => { - it('prefers the bundler implementation when available', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('prefers the bundler implementation when available', async () => { + const { resolveRspackRuntimeImplementation } = + await importModuleFederationPlugin(); const resolve = jest.fn((request: string) => { if (request === '@module-federation/runtime-tools/bundler') { return '/workspace/runtime-tools/dist/bundler.js'; @@ -18,7 +98,8 @@ describe('runtime resolution compatibility', () => { ); }); - it('falls back to legacy esm runtime entries for older implementations', () => { + it('falls back to legacy esm runtime entries for older implementations', async () => { + const { resolveRspackRuntimeAlias } = await importModuleFederationPlugin(); const resolve = jest.fn( (request: string, options?: { paths?: string[] }) => { const basedFromLegacy = options?.paths?.[0] === '/legacy/runtime-tools'; @@ -42,7 +123,8 @@ describe('runtime resolution compatibility', () => { ); }); - it('falls back to legacy cjs runtime entries when esm legacy builds are unavailable', () => { + it('falls back to legacy cjs runtime entries when esm legacy builds are unavailable', async () => { + const { resolveRspackRuntimeAlias } = await importModuleFederationPlugin(); const resolve = jest.fn( (request: string, options?: { paths?: string[] }) => { const basedFromLegacy = options?.paths?.[0] === '/legacy/runtime-tools'; @@ -67,3 +149,21 @@ describe('runtime resolution compatibility', () => { ); }); }); + +describe('ModuleFederationPlugin', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('does not load the DTS plugin when the plugin module is imported', async () => { + const dtsPluginFactory = jest.fn(() => { + throw new Error('DTS plugin should not be loaded'); + }); + mockModuleFederationPluginDependencies(dtsPluginFactory); + + await expect( + import('../src/ModuleFederationPlugin'), + ).resolves.toBeDefined(); + expect(dtsPluginFactory).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/rspack/src/ModuleFederationPlugin.ts b/packages/rspack/src/ModuleFederationPlugin.ts index f19e036b5f7..7448f431a7a 100644 --- a/packages/rspack/src/ModuleFederationPlugin.ts +++ b/packages/rspack/src/ModuleFederationPlugin.ts @@ -13,10 +13,10 @@ import { import { StatsPlugin } from '@module-federation/manifest'; import { ContainerManager, utils } from '@module-federation/managers'; -import { DtsPlugin } from '@module-federation/dts-plugin'; import ReactBridgePlugin from '@module-federation/bridge-react-webpack-plugin'; import path from 'node:path'; import fs from 'node:fs'; +import { createRequire } from 'node:module'; import { RemoteEntryPlugin } from './RemoteEntryPlugin'; import logger from './logger'; @@ -26,9 +26,20 @@ type NonUndefined = ExcludeFalse; type NonFalseSplitChunks = NonUndefined; type CacheGroups = NonUndefined; type CacheGroup = CacheGroups[string]; +type DtsPluginModule = { + DtsPlugin: new ( + options: moduleFederationPlugin.ModuleFederationPluginOptions, + ) => { + apply(compiler: Compiler): void; + addRuntimePlugins(): void; + }; +}; declare const __VERSION__: string; export const PLUGIN_NAME = 'RspackModuleFederationPlugin'; +// Rslib rewrites import.meta.url for both CJS and ESM outputs. +// @ts-ignore +const nodeRequire = createRequire(import.meta.url); type ResolveFn = typeof require.resolve; type RuntimeEntrySpec = { @@ -199,6 +210,9 @@ export class ModuleFederationPlugin implements RspackPluginInstance { let disableDts = options.dts === false; if (!disableDts) { + const { DtsPlugin } = nodeRequire( + '@module-federation/dts-plugin', + ) as DtsPluginModule; const dtsPlugin = new DtsPlugin(options); // @ts-ignore dtsPlugin.apply(compiler);