Skip to content

Commit ef448c7

Browse files
y-oktclaude
andcommitted
fix(webpack-bundler-runtime): correct ESM default export handling for mjs files
Fix ESM interop issue where .mjs files received module namespace objects instead of default exports when using Module Federation with remotes. The runtime now intelligently unwraps ESM namespace objects for object/function default exports while preserving the namespace for primitive defaults to maintain named export accessibility. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent f53f239 commit ef448c7

File tree

4 files changed

+467
-12
lines changed

4 files changed

+467
-12
lines changed

.changeset/early-eggs-attack.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@module-federation/webpack-bundler-runtime': patch
3+
---
4+
5+
Resolve module (mjs) correctly on runtime by changing consumes.ts and installInitialConsumes.ts
Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
1+
import { consumes } from '../src/consumes';
2+
import { installInitialConsumes } from '../src/installInitialConsumes';
3+
import type {
4+
ConsumesOptions,
5+
InstallInitialConsumesOptions,
6+
} from '../src/types';
7+
8+
// Mock attachShareScopeMap as it's used in consumes
9+
jest.mock('../src/attachShareScopeMap', () => ({
10+
attachShareScopeMap: jest.fn(),
11+
}));
12+
13+
describe('ESM Interop', () => {
14+
beforeEach(() => {
15+
jest.clearAllMocks();
16+
});
17+
18+
describe('consumes', () => {
19+
test('should unwrap default export and add circular reference for ESM Namespace Object with object/function default', async () => {
20+
const mockModuleId = 'esmModule';
21+
const mockPromises: Promise<any>[] = [];
22+
const mockDefaultExport = function defaultFn() {
23+
return 'default';
24+
};
25+
const mockNamespaceObject = {
26+
default: mockDefaultExport,
27+
named: 'named',
28+
[Symbol.toStringTag]: 'Module',
29+
};
30+
31+
const mockFactory = jest.fn().mockReturnValue(mockNamespaceObject);
32+
// consumes uses loadShare which returns a promise of the factory
33+
const mockLoadSharePromise = Promise.resolve(mockFactory);
34+
35+
const mockFederationInstance = {
36+
loadShare: jest.fn().mockReturnValue(mockLoadSharePromise),
37+
};
38+
39+
const mockWebpackRequire = {
40+
o: jest
41+
.fn()
42+
.mockImplementation((obj, key) =>
43+
Object.prototype.hasOwnProperty.call(obj, key),
44+
),
45+
m: {},
46+
c: {},
47+
federation: {
48+
instance: mockFederationInstance,
49+
},
50+
};
51+
52+
const mockOptions: ConsumesOptions = {
53+
chunkId: 'testChunkId',
54+
promises: mockPromises,
55+
chunkMapping: {
56+
testChunkId: [mockModuleId],
57+
},
58+
installedModules: {},
59+
moduleToHandlerMapping: {
60+
[mockModuleId]: {
61+
shareKey: 'shareKey',
62+
getter: jest.fn(),
63+
shareInfo: {
64+
scope: ['default'],
65+
shareConfig: { singleton: true, requiredVersion: '1.0.0' },
66+
},
67+
},
68+
},
69+
webpackRequire: mockWebpackRequire as any,
70+
};
71+
72+
// Execute
73+
consumes(mockOptions);
74+
75+
// Wait for the promise to resolve
76+
await mockPromises[0];
77+
78+
// Execute the installed module
79+
const moduleObj = { exports: {} };
80+
mockWebpackRequire.m[mockModuleId](moduleObj);
81+
82+
// Verify the fix:
83+
// 1. module.exports should be the default export function itself
84+
expect(moduleObj.exports).toBe(mockDefaultExport);
85+
86+
// 2. module.exports.default should point to itself (circular reference)
87+
expect((moduleObj.exports as any).default).toBe(moduleObj.exports);
88+
89+
// 3. Named exports should be available on the function object
90+
expect((moduleObj.exports as any).named).toBe('named');
91+
});
92+
93+
test('should NOT unwrap if not an ESM Namespace Object', async () => {
94+
const mockModuleId = 'cjsModule';
95+
const mockPromises: Promise<any>[] = [];
96+
const mockExports = {
97+
default: 'default',
98+
named: 'named',
99+
// No Symbol.toStringTag === 'Module'
100+
};
101+
102+
const mockFactory = jest.fn().mockReturnValue(mockExports);
103+
const mockLoadSharePromise = Promise.resolve(mockFactory);
104+
105+
const mockFederationInstance = {
106+
loadShare: jest.fn().mockReturnValue(mockLoadSharePromise),
107+
};
108+
109+
const mockWebpackRequire = {
110+
o: jest
111+
.fn()
112+
.mockImplementation((obj, key) =>
113+
Object.prototype.hasOwnProperty.call(obj, key),
114+
),
115+
m: {},
116+
c: {},
117+
federation: {
118+
instance: mockFederationInstance,
119+
},
120+
};
121+
122+
const mockOptions: ConsumesOptions = {
123+
chunkId: 'testChunkId',
124+
promises: mockPromises,
125+
chunkMapping: {
126+
testChunkId: [mockModuleId],
127+
},
128+
installedModules: {},
129+
moduleToHandlerMapping: {
130+
[mockModuleId]: {
131+
shareKey: 'shareKey',
132+
getter: jest.fn(),
133+
shareInfo: {
134+
scope: ['default'],
135+
shareConfig: { singleton: true, requiredVersion: '1.0.0' },
136+
},
137+
},
138+
},
139+
webpackRequire: mockWebpackRequire as any,
140+
};
141+
142+
consumes(mockOptions);
143+
await mockPromises[0];
144+
145+
const moduleObj = { exports: {} };
146+
mockWebpackRequire.m[mockModuleId](moduleObj);
147+
148+
// Should be untouched
149+
expect(moduleObj.exports).toBe(mockExports);
150+
expect((moduleObj.exports as any).default).toBe('default');
151+
// Circular reference should NOT be added
152+
expect((moduleObj.exports as any).default).not.toBe(moduleObj.exports);
153+
});
154+
155+
test('should NOT unwrap ESM Namespace Object with primitive default export', async () => {
156+
const mockModuleId = 'esmPrimitiveModule';
157+
const mockPromises: Promise<any>[] = [];
158+
const mockNamespaceObject = {
159+
default: 'primitiveDefault',
160+
version: '1.3.4',
161+
[Symbol.toStringTag]: 'Module',
162+
};
163+
164+
const mockFactory = jest.fn().mockReturnValue(mockNamespaceObject);
165+
const mockLoadSharePromise = Promise.resolve(mockFactory);
166+
167+
const mockFederationInstance = {
168+
loadShare: jest.fn().mockReturnValue(mockLoadSharePromise),
169+
};
170+
171+
const mockWebpackRequire = {
172+
o: jest
173+
.fn()
174+
.mockImplementation((obj, key) =>
175+
Object.prototype.hasOwnProperty.call(obj, key),
176+
),
177+
m: {},
178+
c: {},
179+
federation: {
180+
instance: mockFederationInstance,
181+
},
182+
};
183+
184+
const mockOptions: ConsumesOptions = {
185+
chunkId: 'testChunkId',
186+
promises: mockPromises,
187+
chunkMapping: {
188+
testChunkId: [mockModuleId],
189+
},
190+
installedModules: {},
191+
moduleToHandlerMapping: {
192+
[mockModuleId]: {
193+
shareKey: 'shareKey',
194+
getter: jest.fn(),
195+
shareInfo: {
196+
scope: ['default'],
197+
shareConfig: { singleton: true, requiredVersion: '1.0.0' },
198+
},
199+
},
200+
},
201+
webpackRequire: mockWebpackRequire as any,
202+
};
203+
204+
consumes(mockOptions);
205+
await mockPromises[0];
206+
207+
const moduleObj = { exports: {} };
208+
mockWebpackRequire.m[mockModuleId](moduleObj);
209+
210+
// Should keep original ESM namespace to preserve named exports
211+
expect(moduleObj.exports).toBe(mockNamespaceObject);
212+
expect((moduleObj.exports as any).default).toBe('primitiveDefault');
213+
expect((moduleObj.exports as any).version).toBe('1.3.4');
214+
// Should NOT have circular reference since we didn't unwrap
215+
expect((moduleObj.exports as any).default).not.toBe(moduleObj.exports);
216+
});
217+
});
218+
219+
describe('installInitialConsumes', () => {
220+
test('should unwrap default export and add circular reference for ESM Namespace Object with object/function default', () => {
221+
const mockModuleId = 'esmModuleInitial';
222+
const mockDefaultExport = function defaultFn() {
223+
return 'default';
224+
};
225+
const mockNamespaceObject = {
226+
default: mockDefaultExport,
227+
named: 'named',
228+
[Symbol.toStringTag]: 'Module',
229+
};
230+
231+
const mockFactory = jest.fn().mockReturnValue(mockNamespaceObject);
232+
233+
const mockFederationInstance = {
234+
loadShareSync: jest.fn().mockReturnValue(mockFactory),
235+
};
236+
237+
const mockWebpackRequire = {
238+
m: {},
239+
c: {},
240+
federation: {
241+
instance: mockFederationInstance,
242+
},
243+
};
244+
245+
const mockOptions: InstallInitialConsumesOptions = {
246+
moduleToHandlerMapping: {
247+
[mockModuleId]: {
248+
shareKey: 'shareKey',
249+
getter: jest.fn(),
250+
shareInfo: {
251+
scope: ['default'],
252+
shareConfig: {
253+
singleton: true,
254+
requiredVersion: '1.0.0',
255+
},
256+
},
257+
},
258+
},
259+
webpackRequire: mockWebpackRequire as any,
260+
installedModules: {},
261+
initialConsumes: [mockModuleId],
262+
};
263+
264+
// Execute
265+
installInitialConsumes(mockOptions);
266+
267+
// Execute the installed module factory
268+
const moduleObj = { exports: {} };
269+
mockWebpackRequire.m[mockModuleId](moduleObj);
270+
271+
// Verify
272+
expect(moduleObj.exports).toBe(mockDefaultExport);
273+
expect((moduleObj.exports as any).default).toBe(moduleObj.exports);
274+
expect((moduleObj.exports as any).named).toBe('named');
275+
});
276+
277+
test('should NOT unwrap if not an ESM Namespace Object', () => {
278+
const mockModuleId = 'cjsModuleInitial';
279+
const mockExports = {
280+
default: 'default',
281+
named: 'named',
282+
};
283+
284+
const mockFactory = jest.fn().mockReturnValue(mockExports);
285+
286+
const mockFederationInstance = {
287+
loadShareSync: jest.fn().mockReturnValue(mockFactory),
288+
};
289+
290+
const mockWebpackRequire = {
291+
m: {},
292+
c: {},
293+
federation: {
294+
instance: mockFederationInstance,
295+
},
296+
};
297+
298+
const mockOptions: InstallInitialConsumesOptions = {
299+
moduleToHandlerMapping: {
300+
[mockModuleId]: {
301+
shareKey: 'shareKey',
302+
getter: jest.fn(),
303+
shareInfo: {
304+
scope: ['default'],
305+
shareConfig: {
306+
singleton: true,
307+
requiredVersion: '1.0.0',
308+
},
309+
},
310+
},
311+
},
312+
webpackRequire: mockWebpackRequire as any,
313+
installedModules: {},
314+
initialConsumes: [mockModuleId],
315+
};
316+
317+
installInitialConsumes(mockOptions);
318+
319+
const moduleObj = { exports: {} };
320+
mockWebpackRequire.m[mockModuleId](moduleObj);
321+
322+
expect(moduleObj.exports).toBe(mockExports);
323+
expect((moduleObj.exports as any).default).toBe('default');
324+
expect((moduleObj.exports as any).default).not.toBe(moduleObj.exports);
325+
});
326+
327+
test('should NOT unwrap ESM Namespace Object with primitive default export', () => {
328+
const mockModuleId = 'esmPrimitiveModuleInitial';
329+
const mockNamespaceObject = {
330+
default: 'primitiveDefault',
331+
version: '1.3.4',
332+
[Symbol.toStringTag]: 'Module',
333+
};
334+
335+
const mockFactory = jest.fn().mockReturnValue(mockNamespaceObject);
336+
337+
const mockFederationInstance = {
338+
loadShareSync: jest.fn().mockReturnValue(mockFactory),
339+
};
340+
341+
const mockWebpackRequire = {
342+
m: {},
343+
c: {},
344+
federation: {
345+
instance: mockFederationInstance,
346+
},
347+
};
348+
349+
const mockOptions: InstallInitialConsumesOptions = {
350+
moduleToHandlerMapping: {
351+
[mockModuleId]: {
352+
shareKey: 'shareKey',
353+
getter: jest.fn(),
354+
shareInfo: {
355+
scope: ['default'],
356+
shareConfig: {
357+
singleton: true,
358+
requiredVersion: '1.0.0',
359+
},
360+
},
361+
},
362+
},
363+
webpackRequire: mockWebpackRequire as any,
364+
installedModules: {},
365+
initialConsumes: [mockModuleId],
366+
};
367+
368+
installInitialConsumes(mockOptions);
369+
370+
const moduleObj = { exports: {} };
371+
mockWebpackRequire.m[mockModuleId](moduleObj);
372+
373+
// Should keep original ESM namespace to preserve named exports
374+
expect(moduleObj.exports).toBe(mockNamespaceObject);
375+
expect((moduleObj.exports as any).default).toBe('primitiveDefault');
376+
expect((moduleObj.exports as any).version).toBe('1.3.4');
377+
// Should NOT have circular reference since we didn't unwrap
378+
expect((moduleObj.exports as any).default).not.toBe(moduleObj.exports);
379+
});
380+
});
381+
});

0 commit comments

Comments
 (0)