Files
kandev/apps/web/lib/plugins/duplicate-render.test.tsx
T
João Salavisa b85ce080d5 fix(plugins): make plugin reload idempotent so re-boot never duplicates slots (#1914)
Installed plugins rendered duplicate UI slot widgets (e.g. the chat-input-actions icon twice) because the append-only slot registry could run a plugin's initialize() again while its prior registrations were still live.

- Make loadPlugin idempotent: revoke a plugin's prior registrations before re-running initialize().
- Fence overlapping/concurrent loads by a per-plugin generation so the newest-initiated load wins and a superseded (older) load can't revoke the successor or append a stale registration.
- Wrap the scoped registry reflectively so every register* method (incl. registerKeybinding) is fenced, never dropped.

Tests: registry-count regression, real-DOM PluginSlot render reproduction, two overlapping-load regressions, and a registerKeybinding-forwarding regression.
2026-07-24 14:31:11 +01:00

102 lines
3.5 KiB
TypeScript

import { describe, it, expect, afterEach, vi } from "vitest";
import * as React from "react";
import { render, screen, cleanup } from "@testing-library/react";
import { loadPlugins } from "./host";
import { pluginRegistry } from "./registry";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import type { ActivePlugin, PluginHostApi, PluginRegistry } from "./types";
vi.mock("@/lib/config", () => ({ getBackendConfig: () => ({ apiBaseUrl: "" }) }));
const PLUGIN_ID = "kandev-plugin-render-dup";
const SLOT = "chat-input-actions";
const ICON_TESTID = "plugin-cost-icon";
/**
* Host factory that hands the plugin a *real* React + jsx, exactly like the
* production host (lib/plugins/host-api.ts) — so the plugin's registered slot
* component renders real DOM we can see, the same way the fixture bundle
* (apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js) draws its
* widget through host.React / host.jsx.
*/
function makeHostFactory(pluginId: string): PluginHostApi {
return {
pluginId,
React,
jsx: React.createElement,
store: { getState: () => ({}) as never, setState: () => {}, subscribe: () => () => {} },
api: { fetch: async () => new Response(), baseUrl: "" },
ui: {},
theme: "light",
navigate: () => {},
openModal: () => ({ close: () => {} }),
};
}
/**
* Fake dynamic import that mimics the browser ESM cache: the bundle's
* top-level `registerKandevPlugin(...)` runs only on the *first* import of a
* given specifier; a later import of the same URL resolves from cache without
* re-executing it. The registered plugin draws a single icon into the slot.
*/
function makeImporter() {
let imported = 0;
return async (_url: string) => {
imported += 1;
if (imported === 1) {
(
window as unknown as { registerKandevPlugin: (id: string, plugin: unknown) => void }
).registerKandevPlugin(PLUGIN_ID, {
initialize: (registry: PluginRegistry, host: PluginHostApi) => {
const jsx = host.jsx;
registry.registerComponent(SLOT, function CostIcon() {
return jsx(
"button",
{
"data-testid": ICON_TESTID,
"aria-label": "Session cost",
className: "cursor-pointer",
},
"🪙",
);
});
},
});
}
return {};
};
}
afterEach(() => {
cleanup();
pluginRegistry.unregisterPlugin(PLUGIN_ID);
});
describe("chat-input-actions renders one plugin icon after a re-boot (the reported duplicate)", () => {
it("shows exactly one icon in the real toolbar slot when the plugin is loaded twice without an explicit unload", async () => {
const plugin: ActivePlugin = {
id: PLUGIN_ID,
name: "Session Cost",
bundleUrl: `/api/plugins/${PLUGIN_ID}/bundle?v=1`,
};
const importer = makeImporter();
// First boot loads the plugin (registers its chat-bar icon).
await loadPlugins([plugin], makeHostFactory, importer);
// A second load with no unloadPlugin() in between — the boot re-entry the
// #1861 cache-bust fix cannot cover (boot race / dev HMR / new store). The
// cached bundle registration is reused, so initialize() runs again.
await loadPlugins([plugin], makeHostFactory, importer);
render(
<PluginSlot
name={SLOT}
slotProps={{ taskId: null, activeSessionId: null, sessionIds: [] }}
/>,
);
// Before the fix this renders two identical icons (the screenshot).
expect(screen.getAllByTestId(ICON_TESTID)).toHaveLength(1);
});
});