From 42766e02e8ca58d424380f2d08d2266058c19bae Mon Sep 17 00:00:00 2001 From: Maksim Date: Tue, 7 Jul 2026 14:46:02 +0300 Subject: [PATCH 1/3] feat(mf-host): time-bound manifest fetch and remote load in registerRemoteModules Neither the manifest fetch nor loadRemote was time-bounded, so a hung endpoint or hung remote could leave modulesReady stuck at false and hang the loading gate forever. Add a withTimeout helper and two optional, defaulted options (manifestTimeoutMs=5000, loadTimeoutMs=10000): - manifest fetch is bounded via AbortSignal + withTimeout; a timeout/abort degrades to the existing warn+skip policy, while a real network error still surfaces as modulesLoadError. - each remote load is bounded; a timed-out remote lands in the existing failed[] path without tripping modulesLoadError, and a late settle after the budget is logged. --- .../src/register-remote-modules.test.ts | 159 ++++++++++++++++++ .../mf-host/src/register-remote-modules.ts | 87 ++++++++-- 2 files changed, 235 insertions(+), 11 deletions(-) diff --git a/packages/mf-host/src/register-remote-modules.test.ts b/packages/mf-host/src/register-remote-modules.test.ts index 21ecfd761..1ee7af8d0 100644 --- a/packages/mf-host/src/register-remote-modules.test.ts +++ b/packages/mf-host/src/register-remote-modules.test.ts @@ -455,4 +455,163 @@ describe("registerRemoteModules", () => { expect(marks).toEqual(["vc:modules-start", "vc:modules-done"]); }); }); + + describe("resilience — manifest timeout", () => { + it("manifest fetch that never settles → modulesReady=true after the budget, warn, no loadError", async () => { + vi.useFakeTimers(); + try { + const { app, router } = createTestApp(); + const provided = spyProvide(app); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + // Never settles — only the timeout can end the wait. + (global.fetch as any).mockReturnValue(new Promise(() => {})); + + registerRemoteModules(app, { router, appName: "test-app", manifestTimeoutMs: 5000 }); + await vi.advanceTimersByTimeAsync(5001); + + expect(provided.get(MockModulesReadyKey).value).toBe(true); + expect(provided.get(MockModulesLoadErrorKey).value).toBe(false); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + } finally { + vi.useRealTimers(); + } + }); + + it("respects a custom manifestTimeoutMs (does not fire at the default)", async () => { + vi.useFakeTimers(); + try { + const { app, router } = createTestApp(); + const provided = spyProvide(app); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + (global.fetch as any).mockReturnValue(new Promise(() => {})); + + registerRemoteModules(app, { router, appName: "test-app", manifestTimeoutMs: 1000 }); + await vi.advanceTimersByTimeAsync(1001); + + expect(provided.get(MockModulesReadyKey).value).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("real AbortError (DOMException) from fetch → treated as skip, not loadError", async () => { + const { app, router } = createTestApp(); + const provided = spyProvide(app); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + // Simulates AbortSignal.timeout() firing and fetch rejecting with the real DOMException. + (global.fetch as any).mockRejectedValue(new DOMException("aborted", "AbortError")); + + registerRemoteModules(app, { router, appName: "test-app" }); + await flushPromises(); + + expect(provided.get(MockModulesReadyKey).value).toBe(true); + expect(provided.get(MockModulesLoadErrorKey).value).toBe(false); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("passes an AbortSignal to fetch so the manifest request is actually abortable", async () => { + const { app, router } = createTestApp(); + (global.fetch as any).mockResolvedValue(manifest200([])); + + registerRemoteModules(app, { router, appName: "test-app" }); + await flushPromises(); + + const init = (global.fetch as any).mock.calls[0][1]; + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + }); + + describe("resilience — remote load timeout", () => { + it("a remote whose loadRemote never settles fails after the budget; others still install", async () => { + vi.useFakeTimers(); + try { + const { app, router } = createTestApp(); + const provided = spyProvide(app); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + (global.fetch as any).mockResolvedValue( + manifest200([ + makePlugin({ id: "hanging", remote: { name: "Hanging", exposed: "./Module" } }), + makePlugin({ id: "working", remote: { name: "Working", exposed: "./Module" } }), + ]), + ); + const workingInstall = vi.fn(); + mockLoadRemote + .mockReturnValueOnce(new Promise(() => {})) // hanging — only the timeout ends it + .mockResolvedValueOnce({ default: { install: workingInstall } }); + + registerRemoteModules(app, { router, appName: "test-app", loadTimeoutMs: 1000 }); + await vi.advanceTimersByTimeAsync(1001); + + expect(workingInstall).toHaveBeenCalled(); + expect(provided.get(MockModulesReadyKey).value).toBe(true); + // A failed remote is fail-open: shell renders, no hard error. + expect(provided.get(MockModulesLoadErrorKey).value).toBe(false); + expect(error).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("respects a custom loadTimeoutMs (does not resolve before the budget)", async () => { + vi.useFakeTimers(); + try { + const { app, router } = createTestApp(); + const provided = spyProvide(app); + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + (global.fetch as any).mockResolvedValue(manifest200([makePlugin()])); + mockLoadRemote.mockReturnValue(new Promise(() => {})); + + registerRemoteModules(app, { router, appName: "test-app", loadTimeoutMs: 2000 }); + + await vi.advanceTimersByTimeAsync(1999); + expect(provided.get(MockModulesReadyKey).value).toBe(false); + + await vi.advanceTimersByTimeAsync(2); + expect(provided.get(MockModulesReadyKey).value).toBe(true); + expect(provided.get(MockModulesLoadErrorKey).value).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("logs a late settle when a timed-out remote's loadRemote eventually resolves", async () => { + vi.useFakeTimers(); + try { + const { app, router } = createTestApp(); + const provided = spyProvide(app); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + (global.fetch as any).mockResolvedValue(manifest200([makePlugin({ id: "late-settler" })])); + + let resolveLoad: (value: unknown) => void; + mockLoadRemote.mockReturnValueOnce( + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + + registerRemoteModules(app, { router, appName: "test-app", loadTimeoutMs: 1000 }); + + // Advance past the budget — the remote should be treated as failed. + await vi.advanceTimersByTimeAsync(1001); + expect(provided.get(MockModulesReadyKey).value).toBe(true); + expect(provided.get(MockModulesLoadErrorKey).value).toBe(false); + expect(error).toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("after its")); + + // Now let the underlying loadRemote promise settle, well past the budget. + resolveLoad!({ default: { install: vi.fn() } }); + await vi.advanceTimersByTimeAsync(1000); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining("after its")); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("late-settler")); + } finally { + vi.useRealTimers(); + } + }); + }); }); diff --git a/packages/mf-host/src/register-remote-modules.ts b/packages/mf-host/src/register-remote-modules.ts index 6f55e1674..c0ddff811 100644 --- a/packages/mf-host/src/register-remote-modules.ts +++ b/packages/mf-host/src/register-remote-modules.ts @@ -72,10 +72,33 @@ export interface RegisterRemoteModulesOptions { * Useful for ops debugging or non-standard deployments. */ manifestUrl?: string; + /** Budget for the manifest fetch. Default 5000ms. On expiry: warn + skip (modulesReady=true). */ + manifestTimeoutMs?: number; + /** Budget for one remote's loadRemote. Default 10000ms. On expiry: that remote → failed. */ + loadTimeoutMs?: number; } // --- Helper functions --- +export const DEFAULT_MANIFEST_TIMEOUT_MS = 5000; +export const DEFAULT_LOAD_TIMEOUT_MS = 10000; + +/** Distinguishes a budget expiry from the work's own failure. */ +class TimeoutError extends Error {} + +/** Rejects with TimeoutError if `work` has not settled within `ms`. */ +async function withTimeout(work: Promise, ms: number, label: string): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new TimeoutError(`${label} timed out after ${ms}ms`)), ms); + }); + try { + return await Promise.race([work, timeout]); + } finally { + clearTimeout(timer); + } +} + function defaultManifestUrl(appName: string): string { return `/api/apps/${encodeURIComponent(appName)}/manifest`; } @@ -139,12 +162,37 @@ export function registerRemoteModules(app: App, options: RegisterRemoteModulesOp // 1. Fetch manifest const manifestUrl = options.manifestUrl ?? defaultManifestUrl(options.appName); - - const response = await fetch(manifestUrl, { - method: "GET", - credentials: "same-origin", - headers: { Accept: "application/json" }, - }); + const manifestTimeoutMs = options.manifestTimeoutMs ?? DEFAULT_MANIFEST_TIMEOUT_MS; + const loadTimeoutMs = options.loadTimeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS; + + let response: Response; + try { + response = await withTimeout( + fetch(manifestUrl, { + method: "GET", + credentials: "same-origin", + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(manifestTimeoutMs), + }), + manifestTimeoutMs, + "manifest fetch", + ); + } catch (error) { + const isTimeoutOrAbort = + error instanceof TimeoutError || (error instanceof DOMException && error.name === "AbortError"); + if (!isTimeoutOrAbort) { + // Real network failure — let the outer catch set modulesLoadError. + throw error; + } + // Timeout or abort — same policy as an HTTP error: warn + skip, not a loadError. + console.warn( + `[mf-host] manifest endpoint ${manifestUrl} did not respond within ${manifestTimeoutMs}ms; skipping plugin discovery.`, + error, + ); + modulesReady.value = true; + performance.mark("vc:modules-done"); + return; + } if (!response.ok) { // Unified policy: 401/403/404/5xx → warn + skip, no loadError. @@ -197,12 +245,29 @@ export function registerRemoteModules(app: App, options: RegisterRemoteModulesOp shared, }); - // 4. Load all remotes in parallel + // 4. Load all remotes in parallel, each bounded by loadTimeoutMs. + // A hung remote (remoteEntry.js or its chunks never arriving) would + // otherwise block Promise.allSettled forever and modulesReady would + // never flip. On expiry the remote rejects with TimeoutError and lands + // in `failed` — same path as any other load failure. const results = await Promise.allSettled( - entries.map(async (e) => ({ - entry: e, - exports: await mfInstance.loadRemote(`${e.remoteName}/${e.exposedKey}`), - })), + entries.map(async (e) => { + const work = mfInstance.loadRemote(`${e.remoteName}/${e.exposedKey}`); + try { + return { entry: e, exports: await withTimeout(work, loadTimeoutMs, `loadRemote "${e.id}"`) }; + } catch (error) { + // A budget expiry does not cancel the underlying load; log a late settle so a + // recorded "failed" is never silently contradicted by a later success. + if (error instanceof TimeoutError) { + work + .then(() => + console.warn(`[mf-host] remote "${e.id}" load completed after its ${loadTimeoutMs}ms budget`), + ) + .catch(() => undefined); + } + throw error; + } + }), ); performance.mark("vc:modules-loaded"); From 37d93bb56797c1a198604be3b3b9fe5a820cee3b Mon Sep 17 00:00:00 2001 From: Maksim Date: Fri, 17 Jul 2026 16:26:12 +0300 Subject: [PATCH 2/3] refactor(mf-host): eager per-remote install instead of timeout-bounded loading Replaces the manifest/load timeout approach with eager installation: each remote is installed the moment its loadRemote resolves, instead of waiting behind a Promise.allSettled barrier. This stops the slowest remote (e.g. a cold dev-server compile of the shared framework graph) from holding back every other module's menu items and routes. Also pins the vee-validate shared version to the installed 4.15.1, since vee-validate blocks the "./package.json" subpath and exposes no runtime version to resolve at build time. Removes the manifestTimeoutMs/loadTimeoutMs/withTimeout machinery and its resilience tests; the base test suite (24 tests) covers the eager-install path. --- .../src/register-remote-modules.test.ts | 159 ------------------ .../mf-host/src/register-remote-modules.ts | 127 ++++---------- 2 files changed, 29 insertions(+), 257 deletions(-) diff --git a/packages/mf-host/src/register-remote-modules.test.ts b/packages/mf-host/src/register-remote-modules.test.ts index 1ee7af8d0..21ecfd761 100644 --- a/packages/mf-host/src/register-remote-modules.test.ts +++ b/packages/mf-host/src/register-remote-modules.test.ts @@ -455,163 +455,4 @@ describe("registerRemoteModules", () => { expect(marks).toEqual(["vc:modules-start", "vc:modules-done"]); }); }); - - describe("resilience — manifest timeout", () => { - it("manifest fetch that never settles → modulesReady=true after the budget, warn, no loadError", async () => { - vi.useFakeTimers(); - try { - const { app, router } = createTestApp(); - const provided = spyProvide(app); - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); - // Never settles — only the timeout can end the wait. - (global.fetch as any).mockReturnValue(new Promise(() => {})); - - registerRemoteModules(app, { router, appName: "test-app", manifestTimeoutMs: 5000 }); - await vi.advanceTimersByTimeAsync(5001); - - expect(provided.get(MockModulesReadyKey).value).toBe(true); - expect(provided.get(MockModulesLoadErrorKey).value).toBe(false); - expect(warn).toHaveBeenCalled(); - warn.mockRestore(); - } finally { - vi.useRealTimers(); - } - }); - - it("respects a custom manifestTimeoutMs (does not fire at the default)", async () => { - vi.useFakeTimers(); - try { - const { app, router } = createTestApp(); - const provided = spyProvide(app); - vi.spyOn(console, "warn").mockImplementation(() => undefined); - (global.fetch as any).mockReturnValue(new Promise(() => {})); - - registerRemoteModules(app, { router, appName: "test-app", manifestTimeoutMs: 1000 }); - await vi.advanceTimersByTimeAsync(1001); - - expect(provided.get(MockModulesReadyKey).value).toBe(true); - } finally { - vi.useRealTimers(); - } - }); - - it("real AbortError (DOMException) from fetch → treated as skip, not loadError", async () => { - const { app, router } = createTestApp(); - const provided = spyProvide(app); - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); - // Simulates AbortSignal.timeout() firing and fetch rejecting with the real DOMException. - (global.fetch as any).mockRejectedValue(new DOMException("aborted", "AbortError")); - - registerRemoteModules(app, { router, appName: "test-app" }); - await flushPromises(); - - expect(provided.get(MockModulesReadyKey).value).toBe(true); - expect(provided.get(MockModulesLoadErrorKey).value).toBe(false); - expect(warn).toHaveBeenCalled(); - warn.mockRestore(); - }); - - it("passes an AbortSignal to fetch so the manifest request is actually abortable", async () => { - const { app, router } = createTestApp(); - (global.fetch as any).mockResolvedValue(manifest200([])); - - registerRemoteModules(app, { router, appName: "test-app" }); - await flushPromises(); - - const init = (global.fetch as any).mock.calls[0][1]; - expect(init.signal).toBeInstanceOf(AbortSignal); - }); - }); - - describe("resilience — remote load timeout", () => { - it("a remote whose loadRemote never settles fails after the budget; others still install", async () => { - vi.useFakeTimers(); - try { - const { app, router } = createTestApp(); - const provided = spyProvide(app); - const error = vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.spyOn(console, "warn").mockImplementation(() => undefined); - (global.fetch as any).mockResolvedValue( - manifest200([ - makePlugin({ id: "hanging", remote: { name: "Hanging", exposed: "./Module" } }), - makePlugin({ id: "working", remote: { name: "Working", exposed: "./Module" } }), - ]), - ); - const workingInstall = vi.fn(); - mockLoadRemote - .mockReturnValueOnce(new Promise(() => {})) // hanging — only the timeout ends it - .mockResolvedValueOnce({ default: { install: workingInstall } }); - - registerRemoteModules(app, { router, appName: "test-app", loadTimeoutMs: 1000 }); - await vi.advanceTimersByTimeAsync(1001); - - expect(workingInstall).toHaveBeenCalled(); - expect(provided.get(MockModulesReadyKey).value).toBe(true); - // A failed remote is fail-open: shell renders, no hard error. - expect(provided.get(MockModulesLoadErrorKey).value).toBe(false); - expect(error).toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); - - it("respects a custom loadTimeoutMs (does not resolve before the budget)", async () => { - vi.useFakeTimers(); - try { - const { app, router } = createTestApp(); - const provided = spyProvide(app); - vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.spyOn(console, "warn").mockImplementation(() => undefined); - (global.fetch as any).mockResolvedValue(manifest200([makePlugin()])); - mockLoadRemote.mockReturnValue(new Promise(() => {})); - - registerRemoteModules(app, { router, appName: "test-app", loadTimeoutMs: 2000 }); - - await vi.advanceTimersByTimeAsync(1999); - expect(provided.get(MockModulesReadyKey).value).toBe(false); - - await vi.advanceTimersByTimeAsync(2); - expect(provided.get(MockModulesReadyKey).value).toBe(true); - expect(provided.get(MockModulesLoadErrorKey).value).toBe(false); - } finally { - vi.useRealTimers(); - } - }); - - it("logs a late settle when a timed-out remote's loadRemote eventually resolves", async () => { - vi.useFakeTimers(); - try { - const { app, router } = createTestApp(); - const provided = spyProvide(app); - const error = vi.spyOn(console, "error").mockImplementation(() => undefined); - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); - (global.fetch as any).mockResolvedValue(manifest200([makePlugin({ id: "late-settler" })])); - - let resolveLoad: (value: unknown) => void; - mockLoadRemote.mockReturnValueOnce( - new Promise((resolve) => { - resolveLoad = resolve; - }), - ); - - registerRemoteModules(app, { router, appName: "test-app", loadTimeoutMs: 1000 }); - - // Advance past the budget — the remote should be treated as failed. - await vi.advanceTimersByTimeAsync(1001); - expect(provided.get(MockModulesReadyKey).value).toBe(true); - expect(provided.get(MockModulesLoadErrorKey).value).toBe(false); - expect(error).toHaveBeenCalled(); - expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("after its")); - - // Now let the underlying loadRemote promise settle, well past the budget. - resolveLoad!({ default: { install: vi.fn() } }); - await vi.advanceTimersByTimeAsync(1000); - - expect(warn).toHaveBeenCalledWith(expect.stringContaining("after its")); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("late-settler")); - } finally { - vi.useRealTimers(); - } - }); - }); }); diff --git a/packages/mf-host/src/register-remote-modules.ts b/packages/mf-host/src/register-remote-modules.ts index c0ddff811..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 }, @@ -72,33 +76,10 @@ export interface RegisterRemoteModulesOptions { * Useful for ops debugging or non-standard deployments. */ manifestUrl?: string; - /** Budget for the manifest fetch. Default 5000ms. On expiry: warn + skip (modulesReady=true). */ - manifestTimeoutMs?: number; - /** Budget for one remote's loadRemote. Default 10000ms. On expiry: that remote → failed. */ - loadTimeoutMs?: number; } // --- Helper functions --- -export const DEFAULT_MANIFEST_TIMEOUT_MS = 5000; -export const DEFAULT_LOAD_TIMEOUT_MS = 10000; - -/** Distinguishes a budget expiry from the work's own failure. */ -class TimeoutError extends Error {} - -/** Rejects with TimeoutError if `work` has not settled within `ms`. */ -async function withTimeout(work: Promise, ms: number, label: string): Promise { - let timer: ReturnType | undefined; - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new TimeoutError(`${label} timed out after ${ms}ms`)), ms); - }); - try { - return await Promise.race([work, timeout]); - } finally { - clearTimeout(timer); - } -} - function defaultManifestUrl(appName: string): string { return `/api/apps/${encodeURIComponent(appName)}/manifest`; } @@ -162,37 +143,12 @@ export function registerRemoteModules(app: App, options: RegisterRemoteModulesOp // 1. Fetch manifest const manifestUrl = options.manifestUrl ?? defaultManifestUrl(options.appName); - const manifestTimeoutMs = options.manifestTimeoutMs ?? DEFAULT_MANIFEST_TIMEOUT_MS; - const loadTimeoutMs = options.loadTimeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS; - let response: Response; - try { - response = await withTimeout( - fetch(manifestUrl, { - method: "GET", - credentials: "same-origin", - headers: { Accept: "application/json" }, - signal: AbortSignal.timeout(manifestTimeoutMs), - }), - manifestTimeoutMs, - "manifest fetch", - ); - } catch (error) { - const isTimeoutOrAbort = - error instanceof TimeoutError || (error instanceof DOMException && error.name === "AbortError"); - if (!isTimeoutOrAbort) { - // Real network failure — let the outer catch set modulesLoadError. - throw error; - } - // Timeout or abort — same policy as an HTTP error: warn + skip, not a loadError. - console.warn( - `[mf-host] manifest endpoint ${manifestUrl} did not respond within ${manifestTimeoutMs}ms; skipping plugin discovery.`, - error, - ); - modulesReady.value = true; - performance.mark("vc:modules-done"); - return; - } + const response = await fetch(manifestUrl, { + method: "GET", + credentials: "same-origin", + headers: { Accept: "application/json" }, + }); if (!response.ok) { // Unified policy: 401/403/404/5xx → warn + skip, no loadError. @@ -245,65 +201,40 @@ export function registerRemoteModules(app: App, options: RegisterRemoteModulesOp shared, }); - // 4. Load all remotes in parallel, each bounded by loadTimeoutMs. - // A hung remote (remoteEntry.js or its chunks never arriving) would - // otherwise block Promise.allSettled forever and modulesReady would - // never flip. On expiry the remote rejects with TimeoutError and lands - // in `failed` — same path as any other load failure. - const results = await Promise.allSettled( + // 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) => { - const work = mfInstance.loadRemote(`${e.remoteName}/${e.exposedKey}`); try { - return { entry: e, exports: await withTimeout(work, loadTimeoutMs, `loadRemote "${e.id}"`) }; - } catch (error) { - // A budget expiry does not cancel the underlying load; log a late settle so a - // recorded "failed" is never silently contradicted by a later success. - if (error instanceof TimeoutError) { - work - .then(() => - console.warn(`[mf-host] remote "${e.id}" load completed after its ${loadTimeoutMs}ms budget`), - ) - .catch(() => undefined); + 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.`); } - throw error; + } 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; From b982c7b03191fa01eeb85453ae547375f7f6ea00 Mon Sep 17 00:00:00 2001 From: Maksim Date: Fri, 17 Jul 2026 17:49:55 +0300 Subject: [PATCH 3/3] test(mf-host): cover eager per-remote install Adds cases for the eager-install path in registerRemoteModules: - a fast remote installs while a slower remote is still pending (no barrier) - the summary warn reports the loaded/total count and the failed plugin ids - one install line is logged per successfully installed remote --- .../src/register-remote-modules.test.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) 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(); + }); + }); });