From 4be1f16aa21603bbf1d1c97d256b0f99c516239e Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 22 May 2026 15:52:44 +0000 Subject: [PATCH] Notify mounted plugin slots on export registration Co-Authored-By: Paperclip --- ui/src/plugins/slots.test.ts | 62 ++++++++++++++++++++++++++++++++++-- ui/src/plugins/slots.tsx | 22 +++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/ui/src/plugins/slots.test.ts b/ui/src/plugins/slots.test.ts index 4b5b2fa7d60..5fb7b147a59 100644 --- a/ui/src/plugins/slots.test.ts +++ b/ui/src/plugins/slots.test.ts @@ -1,5 +1,28 @@ -import { describe, expect, it } from "vitest"; -import { _collectRegisterableExportNamesForTests } from "./slots"; +// @vitest-environment jsdom + +import { createElement } from "react"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { + PluginSlotMount, + _collectRegisterableExportNamesForTests, + _resetPluginModuleLoader, + registerPluginWebComponent, + type ResolvedPluginSlot, +} from "./slots"; + +let roots: Root[] = []; + +afterEach(() => { + for (const root of roots) { + flushSync(() => { + root.unmount(); + }); + } + roots = []; + _resetPluginModuleLoader(); +}); describe("plugin slot export registration", () => { it("keeps declared missing exports visible for diagnostics", () => { @@ -26,4 +49,39 @@ describe("plugin slot export registration", () => { expect(exports).toEqual(new Set(["Page", "RouteSidebar", "webComponentTag"])); }); + + it("updates an already-mounted placeholder when the slot export registers later", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + const slot: ResolvedPluginSlot = { + type: "routeSidebar", + id: "content-machine-sidebar", + displayName: "Content", + exportName: "ContentMachineRouteSidebar", + routePath: "content-machine", + pluginId: "content-machine-plugin", + pluginKey: "content-machine", + pluginDisplayName: "Content Machine", + pluginVersion: "1.0.0", + }; + + flushSync(() => { + root.render(createElement(PluginSlotMount, { + slot, + context: { companyId: "company-1", companyPrefix: "PAP" }, + missingBehavior: "placeholder", + })); + }); + + expect(container.textContent).toContain("Content Machine: Content"); + + flushSync(() => { + registerPluginWebComponent("content-machine", "ContentMachineRouteSidebar", "paperclip-test-sidebar"); + }); + + expect(container.textContent).not.toContain("Content Machine: Content"); + expect(container.querySelector("paperclip-test-sidebar")).not.toBeNull(); + }); }); diff --git a/ui/src/plugins/slots.tsx b/ui/src/plugins/slots.tsx index 31e882c614b..82cbeb2c0a7 100644 --- a/ui/src/plugins/slots.tsx +++ b/ui/src/plugins/slots.tsx @@ -124,11 +124,30 @@ type UsePluginSlotsResult = { * Keys are `${pluginKey}:${exportName}` to match manifest slot declarations. */ const registry = new Map(); +const registryListeners = new Set<() => void>(); function buildRegistryKey(pluginKey: string, exportName: string): string { return `${pluginKey}:${exportName}`; } +function notifyRegistryListeners(): void { + for (const listener of registryListeners) { + listener(); + } +} + +function usePluginRegistrySubscription(): void { + const [, forceRerender] = useState(0); + + useEffect(() => { + const listener = () => forceRerender((tick) => tick + 1); + registryListeners.add(listener); + return () => { + registryListeners.delete(listener); + }; + }, []); +} + function requiresEntityType(slotType: PluginUiSlotType): boolean { return slotType === "detailTab" || slotType === "taskDetailView" || slotType === "contextMenuItem" || slotType === "commentAnnotation" || slotType === "commentContextMenuItem" || slotType === "projectSidebarItem" || slotType === "toolbarButton"; } @@ -150,6 +169,7 @@ export function registerPluginReactComponent( kind: "react", component, }); + notifyRegistryListeners(); } /** @@ -164,6 +184,7 @@ export function registerPluginWebComponent( kind: "web-component", tagName, }); + notifyRegistryListeners(); } function resolveRegisteredComponent(slot: ResolvedPluginSlot): RegisteredPluginComponent | null { @@ -802,6 +823,7 @@ export function PluginSlotMount({ className, missingBehavior = "hidden", }: PluginSlotMountProps) { + usePluginRegistrySubscription(); const [, forceRerender] = useState(0); const component = resolveRegisteredComponent(slot);