diff --git a/.changeset/dev-entries-official-mf-plugin.md b/.changeset/dev-entries-official-mf-plugin.md new file mode 100644 index 000000000..fc57ee175 --- /dev/null +++ b/.changeset/dev-entries-official-mf-plugin.md @@ -0,0 +1,5 @@ +--- +"@callstack/repack": patch +--- + +Fix `DevelopmentPlugin` crashing with `Cannot read properties of undefined (reading 'exposes')` when an official Module Federation plugin (from `@module-federation/enhanced`, or the built-in `container.ModuleFederationPlugin` of Rspack or webpack) is used with the dev server. The check for official plugins read `exposes` from `plugin.config`, which only Re.Pack's own Module Federation plugins have, instead of `plugin._options`. diff --git a/packages/repack/src/plugins/DevelopmentPlugin.ts b/packages/repack/src/plugins/DevelopmentPlugin.ts index 0f9e56286..d6dccfda3 100644 --- a/packages/repack/src/plugins/DevelopmentPlugin.ts +++ b/packages/repack/src/plugins/DevelopmentPlugin.ts @@ -65,7 +65,7 @@ export class DevelopmentPlugin { } // official MF plugins expose _options property - if ('_options' in plugin && plugin.config.exposes) { + if ('_options' in plugin && plugin._options.exposes) { return plugin._options.name; } diff --git a/packages/repack/src/plugins/__tests__/DevelopmentPlugin.test.ts b/packages/repack/src/plugins/__tests__/DevelopmentPlugin.test.ts new file mode 100644 index 000000000..2d7e2440f --- /dev/null +++ b/packages/repack/src/plugins/__tests__/DevelopmentPlugin.test.ts @@ -0,0 +1,79 @@ +import { ModuleFederationPlugin as MFPluginRspack } from '@module-federation/enhanced/rspack'; +import type { Compiler, EntryNormalized } from '@rspack/core'; +import { DevelopmentPlugin } from '../DevelopmentPlugin.js'; +import { ModuleFederationPluginV2 } from '../ModuleFederationPluginV2.js'; + +const createMockCompiler = (plugins: unknown[]) => { + const EntryPlugin = jest.fn(() => ({ apply: jest.fn() })); + let onEntryOption: (context: string, entry: EntryNormalized) => void = + () => {}; + + const compiler = { + context: __dirname, + options: { + devServer: { hot: true, host: 'localhost', port: 8081 }, + output: { uniqueName: 'test' }, + resolve: { alias: {} }, + module: { rules: [] }, + plugins, + }, + hooks: { + entryOption: { + tap: (_: unknown, callback: typeof onEntryOption) => { + onEntryOption = callback; + }, + }, + }, + webpack: { + rspackVersion: '1.0.0', + DefinePlugin: jest.fn(() => ({ apply: jest.fn() })), + HotModuleReplacementPlugin: jest.fn(() => ({ apply: jest.fn() })), + ProvidePlugin: jest.fn(() => ({ apply: jest.fn() })), + Template: { toIdentifier: (name: string) => name }, + EntryPlugin, + }, + } as unknown as Compiler; + + return { + compiler, + getEntryNames: () => { + onEntryOption(__dirname, { main: { import: ['./index.js'] } }); + const calls = EntryPlugin.mock.calls as unknown as Array< + [string, string, { name: string }] + >; + return [...new Set(calls.map(([, , options]) => options.name))]; + }, + }; +}; + +describe('DevelopmentPlugin', () => { + it('should add development entries to the container of an official MF plugin with exposes', () => { + const { compiler, getEntryNames } = createMockCompiler([ + new MFPluginRspack({ name: 'remote', exposes: { './App': './App.js' } }), + ]); + new DevelopmentPlugin({ platform: 'ios' }).apply(compiler); + + expect(getEntryNames()).toEqual(['main', 'remote']); + }); + + it('should skip an official MF plugin without exposes', () => { + const { compiler, getEntryNames } = createMockCompiler([ + new MFPluginRspack({ name: 'host' }), + ]); + new DevelopmentPlugin({ platform: 'ios' }).apply(compiler); + + expect(getEntryNames()).toEqual(['main']); + }); + + it('should add development entries to the container of a Re.Pack MF plugin with exposes', () => { + const { compiler, getEntryNames } = createMockCompiler([ + new ModuleFederationPluginV2({ + name: 'remote', + exposes: { './App': './App.js' }, + }), + ]); + new DevelopmentPlugin({ platform: 'ios' }).apply(compiler); + + expect(getEntryNames()).toEqual(['main', 'remote']); + }); +});