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 }), ); });