diff --git a/packages/mf-host/src/register-remote-modules.test.ts b/packages/mf-host/src/register-remote-modules.test.ts index 21ecfd761..758ed13d1 100644 --- a/packages/mf-host/src/register-remote-modules.test.ts +++ b/packages/mf-host/src/register-remote-modules.test.ts @@ -455,4 +455,85 @@ describe("registerRemoteModules", () => { expect(marks).toEqual(["vc:modules-start", "vc:modules-done"]); }); }); + + describe("eager install", () => { + it("installs a remote as soon as it resolves, without waiting for slower remotes", async () => { + const { app, router } = createTestApp(); + (global.fetch as any).mockResolvedValue( + manifest200([ + makePlugin({ id: "slow", remote: { name: "Slow", exposed: "./Module" } }), + makePlugin({ id: "fast", remote: { name: "Fast", exposed: "./Module" } }), + ]), + ); + + let resolveSlow!: (value: unknown) => void; + const slowPromise = new Promise((resolve) => { + resolveSlow = resolve; + }); + const slowInstall = vi.fn(); + const fastInstall = vi.fn(); + + mockLoadRemote.mockImplementation((key: string) => + key.startsWith("Slow/") ? slowPromise : Promise.resolve({ default: { install: fastInstall } }), + ); + + registerRemoteModules(app, { router, appName: "test-app" }); + await flushPromises(); + + // Fast remote is installed while the slow one is still pending — no barrier. + expect(fastInstall).toHaveBeenCalled(); + expect(slowInstall).not.toHaveBeenCalled(); + + resolveSlow({ default: { install: slowInstall } }); + await flushPromises(); + + expect(slowInstall).toHaveBeenCalled(); + }); + + it("warns with the loaded/total count and the failed plugin ids when some remotes fail", async () => { + const { app, router } = createTestApp(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + (global.fetch as any).mockResolvedValue( + manifest200([ + makePlugin({ id: "ok-1", remote: { name: "Ok1", exposed: "./Module" } }), + makePlugin({ id: "bad", remote: { name: "Bad", exposed: "./Module" } }), + makePlugin({ id: "ok-2", remote: { name: "Ok2", exposed: "./Module" } }), + ]), + ); + mockLoadRemote.mockImplementation((key: string) => + key.startsWith("Bad/") ? Promise.reject(new Error("boom")) : Promise.resolve({ default: { install: vi.fn() } }), + ); + + registerRemoteModules(app, { router, appName: "test-app" }); + await flushPromises(); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining("2/3 plugins loaded")); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("bad")); + warn.mockRestore(); + }); + + it("logs one install line per successfully installed remote", async () => { + const { app, router } = createTestApp(); + const info = vi.spyOn(console, "info").mockImplementation(() => undefined); + (global.fetch as any).mockResolvedValue( + manifest200([ + makePlugin({ id: "a", remote: { name: "A", exposed: "./Module" } }), + makePlugin({ id: "b", remote: { name: "B", exposed: "./Module" } }), + ]), + ); + // Distinct exports per remote so app.use does not dedupe a shared plugin object. + mockLoadRemote.mockImplementation(() => Promise.resolve({ default: { install: vi.fn() } })); + + registerRemoteModules(app, { router, appName: "test-app" }); + await flushPromises(); + + const installLines = info.mock.calls + .map((c: any[]) => c[0]) + .filter((m: unknown) => typeof m === "string" && m.includes("installed")); + expect(installLines).toHaveLength(2); + info.mockRestore(); + }); + }); }); diff --git a/packages/mf-host/src/register-remote-modules.ts b/packages/mf-host/src/register-remote-modules.ts index 6f55e1674..329fb4611 100644 --- a/packages/mf-host/src/register-remote-modules.ts +++ b/packages/mf-host/src/register-remote-modules.ts @@ -30,7 +30,11 @@ const RUNTIME_LIBS: Record unknown; version: string }> = { vue: { lib: () => Vue, version: vuePkg.version }, "vue-router": { lib: () => VueRouter, version: vueRouterPkg.version }, "vue-i18n": { lib: () => VueI18n, version: vueI18nPkg.version }, - "vee-validate": { lib: () => VeeValidate, version: (VeeValidate as any).version ?? "4.0.0" }, + // vee-validate blocks the "./package.json" subpath in its exports map and exports no + // runtime `version`, so — unlike the deps above — the version cannot be resolved at + // build time and must be pinned here. Keep in sync with the installed vee-validate; + // it only needs to satisfy the remotes' requiredVersion (^4.12.0 in mf-config). + "vee-validate": { lib: () => VeeValidate, version: "4.15.1" }, "lodash-es": { lib: () => LodashEs, version: lodashEsPkg.version }, "@vueuse/core": { lib: () => VueuseCore, version: vueusePkg.version }, "@vc-shell/framework": { lib: () => Framework, version: frameworkPkg.version }, @@ -197,48 +201,40 @@ export function registerRemoteModules(app: App, options: RegisterRemoteModulesOp shared, }); - // 4. Load all remotes in parallel - const results = await Promise.allSettled( - entries.map(async (e) => ({ - entry: e, - exports: await mfInstance.loadRemote(`${e.remoteName}/${e.exposedKey}`), - })), + // 4 + 5. Load each remote in parallel and install it the moment IT resolves, + // rather than waiting for the whole batch behind a Promise.allSettled barrier. + // Otherwise the slowest remote (e.g. a cold dev-server compilation of the shared + // framework graph) holds back every other module's menu items and routes, so + // nothing appears until all remotes have loaded. + const failed: { entry: ModuleRegistryEntry; error: unknown }[] = []; + await Promise.allSettled( + entries.map(async (e) => { + try { + const exports = await mfInstance.loadRemote(`${e.remoteName}/${e.exposedKey}`); + const plugins = resolvePlugins(exports); + if (plugins.length > 0) { + for (const plugin of plugins) app.use(plugin, { router: options.router }); + console.info(`[mf-host] "${e.id}" v${e.version} installed (${plugins.length} sub-module(s)).`); + } else { + console.error(`[mf-host] "${e.id}" has no install function. Skipping.`); + } + } catch (error) { + failed.push({ entry: e, error }); + console.error(`[mf-host] Failed to load "${e.id}":`, error); + } + }), ); performance.mark("vc:modules-loaded"); - - const loaded: { entry: ModuleRegistryEntry; exports: unknown }[] = []; - const failed: { entry: ModuleRegistryEntry; error: unknown }[] = []; - for (let i = 0; i < results.length; i++) { - const r = results[i]; - if (r.status === "fulfilled") loaded.push(r.value); - else failed.push({ entry: entries[i], error: r.reason }); - } - - for (const { entry, error } of failed) { - console.error(`[mf-host] Failed to load "${entry.id}":`, error); - } - - // 5. Install plugins - for (const { entry, exports } of loaded) { - const plugins = resolvePlugins(exports); - if (plugins.length > 0) { - for (const plugin of plugins) app.use(plugin, { router: options.router }); - console.info(`[mf-host] "${entry.id}" v${entry.version} installed (${plugins.length} sub-module(s)).`); - } else { - console.error(`[mf-host] "${entry.id}" has no install function. Skipping.`); - } - } + performance.mark("vc:modules-installed"); if (failed.length > 0) { console.warn( - `[mf-host] ${loaded.length}/${entries.length} plugins loaded. Failed: [${failed + `[mf-host] ${entries.length - failed.length}/${entries.length} plugins loaded. Failed: [${failed .map((f) => f.entry.id) .join(", ")}]`, ); } - - performance.mark("vc:modules-installed"); modulesReady.value = true; const resolvedPath = options.router.currentRoute.value.fullPath;