Files
webmail-client/frontend/src/plugins/PluginSlot.tsx
T

64 lines
1.9 KiB
TypeScript
Raw Normal View History

// Renders every plugin bundle registered for a named UI slot (SPEC §11.3).
//
// The host owns a container <div> and delegates rendering to each bundle's mount
// function (framework-agnostic contract), tearing it down on unmount. Slot-specific
// data (e.g. the current messageId) is passed as `props` at mount time.
import { useEffect, useRef } from "react";
import { loadBundle } from "./bundleLoader";
import { usePluginContext } from "./context";
import { usePluginSlots } from "./manifest";
import type { PluginUnmount } from "./types";
function SlotMount({
pluginName,
bundle,
slotName,
props,
}: {
pluginName: string;
bundle: string;
slotName: string;
props?: Record<string, unknown>;
}) {
const ref = useRef<HTMLDivElement>(null);
const ctx = usePluginContext(pluginName);
useEffect(() => {
let cleanup: PluginUnmount | void;
let alive = true;
loadBundle(bundle)
.then((mount) => {
if (alive && ref.current) cleanup = mount(ref.current, ctx, props);
})
.catch((err) => console.error(`plugin slot ${slotName} (${pluginName}) failed`, err));
return () => {
alive = false;
if (typeof cleanup === "function") cleanup();
};
// props is read once at mount; plugins react to later changes via ctx.events.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bundle, ctx]);
return <div ref={ref} data-plugin-slot={slotName} data-plugin={pluginName} />;
}
export function PluginSlot({ name, props }: { name: string; props?: Record<string, unknown> }) {
const bindings = usePluginSlots(name);
if (bindings.length === 0) return null;
return (
<>
{bindings.map((b) => (
<SlotMount
key={`${b.plugin}:${b.bundle}`}
pluginName={b.plugin}
bundle={b.bundle}
slotName={name}
props={props}
/>
))}
</>
);
}