Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions packages/mf-host/src/register-remote-modules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
62 changes: 29 additions & 33 deletions packages/mf-host/src/register-remote-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ const RUNTIME_LIBS: Record<string, { lib: () => 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 },
Expand Down Expand Up @@ -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;
Expand Down
Loading