From e2f0f22d6433960ddd297fd15e53f752a30a9486 Mon Sep 17 00:00:00 2001 From: Peter Mathis Date: Thu, 10 Sep 2026 13:44:41 +0200 Subject: [PATCH] feat(module federation): resolve a promise once all remote bundles are initialized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document-ready handler started the initialization of all Module Federation remote bundles but did not wait for them: the ``patternslib__mf--loaded`` event fired before any remote had actually loaded and run its main module. Consumers had no way to know when the remotes' patterns and components were registered. The helper now initializes all remotes in parallel, waits for all of them to settle (a failing remote is logged and does not block the others), and only then dispatches the event. The new promise ``window.__patternslib_mf_initialized`` is created at module load time, so it can be awaited by code running before or after document ready — the Patternslib registry uses it to defer the initial DOM scan. A remote's main module usually only does ``import("./bundle")`` — the async boundary needed to consume shared modules — and the registrations happen in that chunk. If the main module exports that promise as its default export (``export default import("./bundle")``), the helper waits for it as well. Remotes without the export keep working as before. --- src/module_federation.test.js | 116 ++++++++++++++++++++++++++++++++++ webpack/module_federation.js | 49 ++++++++++++-- 2 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 src/module_federation.test.js diff --git a/src/module_federation.test.js b/src/module_federation.test.js new file mode 100644 index 0000000..2e50bf8 --- /dev/null +++ b/src/module_federation.test.js @@ -0,0 +1,116 @@ +jest.mock("../webpack/module_federation--dynamic-federation", () => ({ + __esModule: true, + default: jest.fn(async (remote) => globalThis[remote]), +})); + +describe("webpack/module_federation", () => { + const FAKE = "__patternslib_mf__fake"; + const BROKEN = "__patternslib_mf__broken"; + let events; + const on_loaded = () => events.push("loaded"); + + beforeEach(() => { + jest.resetModules(); + events = []; + document.addEventListener("patternslib__mf--loaded", on_loaded); + }); + + afterEach(() => { + document.removeEventListener("patternslib__mf--loaded", on_loaded); + delete window[FAKE]; + delete window[BROKEN]; + delete window.__patternslib_mf_initialized; + delete window.__patternslib_container_map; + jest.restoreAllMocks(); + }); + + it("resolves the initialization promise only after all remotes are initialized", async () => { + let release; + const factory = jest.fn(() => ({})); + window[FAKE] = { + get: jest.fn( + () => new Promise((resolve) => (release = () => resolve(factory))), + ), + }; + + require("../webpack/module_federation"); + + const promise = window.__patternslib_mf_initialized; + expect(promise).toBeInstanceOf(Promise); + + let settled = false; + promise.then(() => (settled = true)); + + // document ready has fired, the remote is being loaded but not done yet. + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(window[FAKE].get).toHaveBeenCalledWith("./main"); + expect(settled).toBe(false); + expect(events).toEqual([]); + + release(); + const bundles = await promise; + + expect(bundles).toEqual([FAKE]); + expect(factory).toHaveBeenCalled(); + expect(window.__patternslib_container_map[`${FAKE}-./main`]).toBe(true); + // The event is dispatched after the promise resolved. + expect(events).toEqual(["loaded"]); + }); + + it("resolves even when a remote fails to initialize", async () => { + const error_spy = jest.spyOn(console, "error").mockImplementation(() => {}); + window[BROKEN] = { + get: jest.fn(async () => { + throw new Error("boom"); + }), + }; + window[FAKE] = { get: jest.fn(async () => () => ({})) }; + + require("../webpack/module_federation"); + const bundles = await window.__patternslib_mf_initialized; + + expect(bundles.sort()).toEqual([BROKEN, FAKE].sort()); + expect(error_spy).toHaveBeenCalledWith( + expect.stringContaining(BROKEN), + expect.any(Error), + ); + expect(window.__patternslib_container_map[`${FAKE}-./main`]).toBe(true); + expect(window.__patternslib_container_map[`${BROKEN}-./main`]).toBeUndefined(); + expect(events).toEqual(["loaded"]); + }); + + it("waits for the promise a remote's main module exports as default", async () => { + let release; + const bundle_code = jest.fn(); + // The main module only starts the dynamic import of the bundle code and + // exports that promise. + const main_module = { + default: new Promise((resolve) => (release = () => resolve(bundle_code()))), + }; + window[FAKE] = { get: jest.fn(async () => () => main_module) }; + + require("../webpack/module_federation"); + + let settled = false; + window.__patternslib_mf_initialized.then(() => (settled = true)); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // main has run, but the bundle code has not — still waiting. + expect(window[FAKE].get).toHaveBeenCalled(); + expect(settled).toBe(false); + expect(events).toEqual([]); + + release(); + await window.__patternslib_mf_initialized; + + expect(bundle_code).toHaveBeenCalled(); + expect(events).toEqual(["loaded"]); + }); + + it("resolves immediately when no remotes are registered", async () => { + require("../webpack/module_federation"); + const bundles = await window.__patternslib_mf_initialized; + expect(bundles).toEqual([]); + expect(events).toEqual(["loaded"]); + }); +}); diff --git a/webpack/module_federation.js b/webpack/module_federation.js index 4f7b4f2..5afd03c 100644 --- a/webpack/module_federation.js +++ b/webpack/module_federation.js @@ -22,10 +22,23 @@ export async function initialize_remote({ remote_name, exposed_module = "./main" const factory = await container.get(exposed_module); const module = factory(); + // A remote's main module usually only does a dynamic import of the + // actual bundle code (``import("./bundle")``) — the async boundary + // webpack needs to consume shared modules. The patterns and components + // are only registered once that chunk has run. If the main module + // exports that import promise as its default export + // (``export default import("./bundle")``), wait for it, so that + // ``__patternslib_mf_initialized`` really resolves after the remote's + // registrations are done. Remotes without such an export keep working + // as before. + if (typeof module?.default?.then === "function") { + await module.default; + } + container_map[`${remote_name}-${exposed_module}`] = true; console.debug( - `Patternslib Module Federation: Loaded and initialized bundle "${remote_name}".` + `Patternslib Module Federation: Loaded and initialized bundle "${remote_name}".`, ); return module; @@ -41,17 +54,41 @@ function document_ready(fn) { } } -document_ready(function () { +// A promise which resolves once all Module Federation enabled bundles have +// been loaded and initialized (or failed to do so). It is created right at +// module load time, so consumers like the Patternslib registry can wait for +// it no matter whether they run before or after document ready. +// The Patternslib registry uses this to defer the initial DOM scan until all +// remote bundles had the chance to register their patterns and components. +let resolve_initialized; +window.__patternslib_mf_initialized = new Promise((resolve) => { + resolve_initialized = resolve; +}); + +document_ready(async function () { // Automatically initialize all Module Federation enabled Patternslib based // bundles by filtering for the prefix ``__patternslib_mf__``. // Do this on document ready, as this is the time where all MF bundles have // been registered in the global namespace. const bundles = Object.keys(window).filter((it) => it.indexOf(MF_NAME_PREFIX) === 0); - for (const bundle_name of bundles) { - // Now load + initialize each bundle. - initialize_remote({ remote_name: bundle_name }); + + // Load + initialize all bundles in parallel and wait for all of them to + // settle. A failing bundle must not block the others, nor the + // initialization of the page. + const results = await Promise.allSettled( + bundles.map((bundle_name) => initialize_remote({ remote_name: bundle_name })), + ); + for (const [index, result] of results.entries()) { + if (result.status === "rejected") { + console.error( + `Patternslib Module Federation: Failed to initialize bundle "${bundles[index]}".`, + result.reason, + ); + } } + + resolve_initialized(bundles); document.dispatchEvent( - new Event("patternslib__mf--loaded", { bubbles: true, cancelable: false }) + new Event("patternslib__mf--loaded", { bubbles: true, cancelable: false }), ); });