From e7e1e0faf21a0cd148848d74dbdcf2dd08fd9e2f Mon Sep 17 00:00:00 2001 From: Shashank Upadhyay Date: Sat, 8 Aug 2026 23:34:55 +0530 Subject: [PATCH] fix(nextjs-mf): await async factories and preserve class semantics in server onLoad proxy --- .changeset/salty-deer-cheat.md | 5 + .../plugins/container/runtimePlugin.test.ts | 178 ++++++++++++++++++ .../src/plugins/container/runtimePlugin.ts | 84 ++++----- 3 files changed, 222 insertions(+), 45 deletions(-) create mode 100644 .changeset/salty-deer-cheat.md diff --git a/.changeset/salty-deer-cheat.md b/.changeset/salty-deer-cheat.md new file mode 100644 index 00000000000..1a284e3dc97 --- /dev/null +++ b/.changeset/salty-deer-cheat.md @@ -0,0 +1,5 @@ +--- +'@module-federation/nextjs-mf': patch +--- + +Fix server-side onLoad crash when async remote module factories are used during the webpack build/SSR path. Await async factory results before proxy-wrapping, return a wrapper factory for exposeModuleFactory, and preserve class constructor semantics via Proxy apply/construct traps. diff --git a/packages/nextjs-mf/src/plugins/container/runtimePlugin.test.ts b/packages/nextjs-mf/src/plugins/container/runtimePlugin.test.ts index 5a5cb9cbf05..88e854e82ff 100644 --- a/packages/nextjs-mf/src/plugins/container/runtimePlugin.test.ts +++ b/packages/nextjs-mf/src/plugins/container/runtimePlugin.test.ts @@ -193,3 +193,181 @@ describe('next-internal-plugin beforeRequest', () => { ); }); }); + +describe('next-internal-plugin onLoad', () => { + const plugin = createRuntimePlugin(); + const onLoad = plugin.onLoad!; + const originalWindow = global.window; + + describe('server', () => { + beforeEach(() => { + delete global.window; + globalThis.usedChunks = new Set(); + }); + + afterEach(() => { + global.window = originalWindow; + }); + + it('awaits async exposeModuleFactory on server before proxy-wrapping', async () => { + const moduleExports = { __esModule: true, default: null }; + const asyncFactory = async () => moduleExports; + + const result = await onLoad({ + id: 'remote/expose', + exposeModuleFactory: asyncFactory, + exposeModule: undefined, + }); + + expect(typeof result).toBe('function'); + expect(result()).toEqual( + expect.objectContaining({ __esModule: true, default: null }), + ); + }); + + it('returns a wrapper factory for async namespace exports on server', async () => { + const result = await onLoad({ + id: 'remote/expose', + exposeModuleFactory: async () => ({ __esModule: true, default: null }), + exposeModule: undefined, + }); + + expect(typeof result).toBe('function'); + expect(result()).toEqual( + expect.objectContaining({ __esModule: true, default: null }), + ); + }); + + it('does not break Promise.prototype.then when async factory resolves on server', async () => { + const asyncFactory = async () => ({ __esModule: true, default: null }); + + const result = await onLoad({ + id: 'remote/expose', + exposeModuleFactory: asyncFactory, + exposeModule: undefined, + }); + + expect(typeof result).toBe('function'); + expect(() => + Promise.resolve(result()).then(() => undefined), + ).not.toThrow(); + }); + + it('handles sync exposeModuleFactory on server', async () => { + const result = await onLoad({ + id: 'remote/expose', + exposeModuleFactory: () => ({ __esModule: true, default: null }), + exposeModule: undefined, + }); + + expect(typeof result).toBe('function'); + expect(result()).toEqual( + expect.objectContaining({ __esModule: true, default: null }), + ); + }); + + it('propagates rejected async factory on server', async () => { + await expect( + onLoad({ + id: 'remote/expose', + exposeModuleFactory: async () => { + throw new Error('factory failed'); + }, + exposeModule: undefined, + }), + ).rejects.toThrow('factory failed'); + }); + + it('keeps class default export constructible after async factory', async () => { + class RemoteComponent { + tag = 'remote'; + } + + const result = await onLoad({ + id: 'remote/expose', + exposeModuleFactory: async () => ({ + __esModule: true, + default: RemoteComponent, + }), + exposeModule: undefined, + }); + + const exports = result(); + const instance = new exports.default(); + + expect(instance).toBeInstanceOf(RemoteComponent); + expect(instance.tag).toBe('remote'); + }); + + it('records usedChunks when class is constructed', async () => { + class RemoteComponent {} + + const result = await onLoad({ + id: 'remote/expose', + exposeModuleFactory: async () => ({ + __esModule: true, + default: RemoteComponent, + }), + exposeModule: undefined, + }); + + const exports = result(); + new exports.default(); + + expect(globalThis.usedChunks.has('remote/expose')).toBe(true); + }); + + it('preserves static properties on function exports', async () => { + const fn = Object.assign(() => 'ok', { + getServerSideProps: () => ({ props: {} }), + }); + + const result = await onLoad({ + id: 'remote/expose', + exposeModuleFactory: async () => ({ + __esModule: true, + default: fn, + }), + exposeModule: undefined, + }); + + const exports = result(); + expect(exports.default()).toBe('ok'); + expect(exports.default.getServerSideProps()).toEqual({ props: {} }); + }); + + it('keeps plain function default export callable', async () => { + const result = await onLoad({ + id: 'remote/expose', + exposeModuleFactory: async () => ({ + __esModule: true, + default: () => 'plain-fn', + }), + exposeModule: undefined, + }); + + const exports = result(); + expect(exports.default()).toBe('plain-fn'); + }); + }); + + describe('client', () => { + afterEach(() => { + global.window = originalWindow; + }); + + it('returns args unchanged on the client', async () => { + global.window = originalWindow ?? ({} as Window & typeof globalThis); + + const input = { + id: 'remote/expose', + exposeModuleFactory: async () => ({ __esModule: true, default: null }), + exposeModule: undefined, + }; + + const result = await onLoad(input); + + expect(result).toBe(input); + }); + }); +}); diff --git a/packages/nextjs-mf/src/plugins/container/runtimePlugin.ts b/packages/nextjs-mf/src/plugins/container/runtimePlugin.ts index 03938f0c8f4..2fead4edc7b 100644 --- a/packages/nextjs-mf/src/plugins/container/runtimePlugin.ts +++ b/packages/nextjs-mf/src/plugins/container/runtimePlugin.ts @@ -1,6 +1,25 @@ import { ModuleFederationRuntimePlugin } from '@module-federation/runtime'; import { matchRemoteWithNameAndExpose } from '@module-federation/runtime-core'; +function wrapCallableForChunkTracking any>( + fn: T, + id: string, +): T { + return new Proxy(fn, { + apply(_target, thisArg, args) { + globalThis.usedChunks.add(id); + return Reflect.apply(fn, thisArg, args); + }, + construct(_target, args, newTarget) { + globalThis.usedChunks.add(id); + return Reflect.construct(fn, args, newTarget); + }, + get(target, prop, receiver) { + return Reflect.get(target, prop, receiver); + }, + }); +} + export default function (): ModuleFederationRuntimePlugin { return { name: 'next-internal-plugin', @@ -113,7 +132,7 @@ export default function (): ModuleFederationRuntimePlugin { afterResolve: function (args: any) { return args; }, - onLoad: function (args: any) { + onLoad: async function (args: any) { const exposeModuleFactory = args.exposeModuleFactory; const exposeModule = args.exposeModule; const id = args.id; @@ -128,60 +147,35 @@ export default function (): ModuleFederationRuntimePlugin { exposedModuleExports = moduleOrFactory; } + exposedModuleExports = await exposedModuleExports; + const handler: ProxyHandler = { get: function (target, prop, receiver) { - if ( - target === exposedModuleExports && - typeof exposedModuleExports[prop] === 'function' - ) { - return function (this: unknown) { - globalThis.usedChunks.add(id); - //eslint-disable-next-line - return exposedModuleExports[prop].apply(this, arguments); - }; - } - - const originalMethod = target[prop]; - if (typeof originalMethod === 'function') { - const proxiedFunction = function (this: unknown) { - globalThis.usedChunks.add(id); - //eslint-disable-next-line - return originalMethod.apply(this, arguments); - }; - - Object.keys(originalMethod).forEach(function (prop) { - Object.defineProperty(proxiedFunction, prop, { - value: originalMethod[prop], - writable: true, - enumerable: true, - configurable: true, - }); - }); - - return proxiedFunction; + const value = Reflect.get(target, prop, receiver); + if (typeof value === 'function') { + return wrapCallableForChunkTracking(value, id); } - - return Reflect.get(target, prop, receiver); + return value; }, }; if (typeof exposedModuleExports === 'function') { - exposedModuleExports = new Proxy(exposedModuleExports, handler); - - const staticProps = Object.getOwnPropertyNames(exposedModuleExports); - staticProps.forEach(function (prop) { - if (typeof exposedModuleExports[prop] === 'function') { - exposedModuleExports[prop] = new Proxy( - exposedModuleExports[prop], - handler, - ); - } - }); + exposedModuleExports = wrapCallableForChunkTracking( + exposedModuleExports, + id, + ); return function () { return exposedModuleExports; }; - } else { - exposedModuleExports = new Proxy(exposedModuleExports, handler); + } + + exposedModuleExports = new Proxy(exposedModuleExports, handler); + + if (exposeModuleFactory) { + const wrappedExports = exposedModuleExports; + return function () { + return wrappedExports; + }; } return exposedModuleExports;