/** * @fileoverview Plugin UI slot system — dynamic loading, error isolation, * and rendering of plugin-contributed UI extensions. * * Provides: * - `usePluginSlots(type, context?)` — React hook that discovers and * filters plugin UI contributions for a given slot type. * - `PluginSlotOutlet` — renders all matching slots inline with error * boundary isolation per plugin. * - `PluginBridgeScope` — wraps each plugin's component tree to inject * the bridge context (`pluginId`, host context) needed by bridge hooks. * * Plugin UI modules are loaded via dynamic ESM `import()` from the host's * static file server (`/_plugins/:pluginId/ui/:entryFile`). Each module * exports named React components that correspond to `ui.slots[].exportName` * in the manifest. * * @see PLUGIN_SPEC.md §19 — UI Extension Model * @see PLUGIN_SPEC.md §19.0.3 — Bundle Serving */ import { Component, createElement, useEffect, useMemo, useRef, useState, type ErrorInfo, type ReactNode, type ComponentType, } from "react"; import { useQuery } from "@tanstack/react-query"; import type { PluginLauncherDeclaration, PluginUiSlotDeclaration, PluginUiSlotEntityType, PluginUiSlotType, } from "@paperclipai/shared"; import { pluginsApi, type PluginUiContribution } from "@/api/plugins"; import { authApi } from "@/api/auth"; import { queryKeys } from "@/lib/queryKeys"; import { cn } from "@/lib/utils"; import { PluginBridgeContext, type PluginHostContext, } from "./bridge"; export type PluginSlotContext = { companyId?: string | null; companyPrefix?: string | null; projectId?: string | null; entityId?: string | null; entityType?: PluginUiSlotEntityType | null; /** Parent entity ID for nested slots (e.g. comment annotations within an issue). */ parentEntityId?: string | null; projectRef?: string | null; }; export type ResolvedPluginSlot = PluginUiSlotDeclaration & { pluginId: string; pluginKey: string; pluginDisplayName: string; pluginVersion: string; }; type PluginSlotComponentProps = { slot: ResolvedPluginSlot; context: PluginSlotContext; }; export type RegisteredPluginComponent = | { kind: "react"; component: ComponentType; } | { kind: "web-component"; tagName: string; }; type SlotFilters = { slotTypes: PluginUiSlotType[]; entityType?: PluginUiSlotEntityType | null; companyId?: string | null; enabled?: boolean; }; type UsePluginSlotsResult = { slots: ResolvedPluginSlot[]; isLoading: boolean; errorMessage: string | null; }; /** * In-memory registry for plugin UI exports loaded by the host page. * Keys are `${pluginKey}:${exportName}` to match manifest slot declarations. */ const registry = new Map(); function buildRegistryKey(pluginKey: string, exportName: string): string { return `${pluginKey}:${exportName}`; } function requiresEntityType(slotType: PluginUiSlotType): boolean { return slotType === "detailTab" || slotType === "taskDetailView" || slotType === "contextMenuItem" || slotType === "commentAnnotation" || slotType === "commentContextMenuItem" || slotType === "projectSidebarItem"; } function getErrorMessage(error: unknown): string { if (error instanceof Error && error.message) return error.message; return "Unknown error"; } /** * Registers a React component export for a plugin UI slot. */ export function registerPluginReactComponent( pluginKey: string, exportName: string, component: ComponentType, ): void { registry.set(buildRegistryKey(pluginKey, exportName), { kind: "react", component, }); } /** * Registers a custom element tag for a plugin UI slot. */ export function registerPluginWebComponent( pluginKey: string, exportName: string, tagName: string, ): void { registry.set(buildRegistryKey(pluginKey, exportName), { kind: "web-component", tagName, }); } function resolveRegisteredComponent(slot: ResolvedPluginSlot): RegisteredPluginComponent | null { return registry.get(buildRegistryKey(slot.pluginKey, slot.exportName)) ?? null; } export function resolveRegisteredPluginComponent( pluginKey: string, exportName: string, ): RegisteredPluginComponent | null { return registry.get(buildRegistryKey(pluginKey, exportName)) ?? null; } // --------------------------------------------------------------------------- // Plugin module dynamic import loader // --------------------------------------------------------------------------- type PluginLoadState = "idle" | "loading" | "loaded" | "error"; /** * Tracks the load state for each plugin's UI module by contribution cache key. * * Once a plugin module is loaded, all its named exports are inspected and * registered into the component `registry` so that `resolveRegisteredComponent` * can find them when slots render. */ const pluginLoadStates = new Map(); /** * Promise cache to prevent concurrent duplicate imports for the same plugin. */ const inflightImports = new Map>(); /** * Build the full URL for a plugin's UI entry module. * * The server serves plugin UI bundles at `/_plugins/:pluginId/ui/*`. * The `uiEntryFile` from the contribution (typically `"index.js"`) is * appended to form the complete import path. */ function buildPluginModuleKey(contribution: PluginUiContribution): string { const cacheHint = contribution.updatedAt ?? contribution.version ?? "0"; return `${contribution.pluginId}:${cacheHint}`; } function buildPluginUiUrl(contribution: PluginUiContribution): string { const cacheHint = encodeURIComponent(contribution.updatedAt ?? contribution.version ?? "0"); return `/_plugins/${encodeURIComponent(contribution.pluginId)}/ui/${contribution.uiEntryFile}?v=${cacheHint}`; } /** * Import a plugin's UI entry module with bare-specifier rewriting. * * Plugin bundles are built with `external: ["@paperclipai/plugin-sdk/ui", "react", "react-dom"]`, * so their ESM output contains bare specifier imports like: * * ```js * import { usePluginData } from "@paperclipai/plugin-sdk/ui"; * import React from "react"; * ``` * * Browsers cannot resolve bare specifiers without an import map. Rather than * fighting import map timing constraints, we: * 1. Fetch the module source text * 2. Rewrite bare specifier imports to use blob URLs that re-export from the * host's global bridge registry (`globalThis.__paperclipPluginBridge__`) * 3. Import the rewritten module via a blob URL * * This approach is compatible with all modern browsers and avoids import map * ordering issues. */ const shimBlobUrls: Record = {}; function applyJsxRuntimeKey( props: Record | null | undefined, key: string | number | undefined, ): Record { if (key === undefined) return props ?? {}; return { ...(props ?? {}), key }; } function getShimBlobUrl(specifier: "react" | "react-dom" | "react-dom/client" | "react/jsx-runtime" | "sdk-ui"): string { if (shimBlobUrls[specifier]) return shimBlobUrls[specifier]; let source: string; switch (specifier) { case "react": source = ` const R = globalThis.__paperclipPluginBridge__?.react; export default R; const { useState, useEffect, useCallback, useMemo, useRef, useContext, createContext, createElement, Fragment, Component, forwardRef, memo, lazy, Suspense, StrictMode, cloneElement, Children, isValidElement, createRef } = R; export { useState, useEffect, useCallback, useMemo, useRef, useContext, createContext, createElement, Fragment, Component, forwardRef, memo, lazy, Suspense, StrictMode, cloneElement, Children, isValidElement, createRef }; `; break; case "react/jsx-runtime": source = ` const R = globalThis.__paperclipPluginBridge__?.react; const withKey = ${applyJsxRuntimeKey.toString()}; export const jsx = (type, props, key) => R.createElement(type, withKey(props, key)); export const jsxs = (type, props, key) => R.createElement(type, withKey(props, key)); export const Fragment = R.Fragment; `; break; case "react-dom": case "react-dom/client": source = ` const RD = globalThis.__paperclipPluginBridge__?.reactDom; export default RD; const { createRoot, hydrateRoot, createPortal, flushSync } = RD ?? {}; export { createRoot, hydrateRoot, createPortal, flushSync }; `; break; case "sdk-ui": source = ` const SDK = globalThis.__paperclipPluginBridge__?.sdkUi ?? {}; const { usePluginData, usePluginAction, useHostContext, usePluginStream, MetricCard, StatusBadge, DataTable, TimeseriesChart, MarkdownBlock, KeyValueList, ActionBar, LogView, JsonTree, Spinner, ErrorBoundary } = SDK; export { usePluginData, usePluginAction, useHostContext, usePluginStream, MetricCard, StatusBadge, DataTable, TimeseriesChart, MarkdownBlock, KeyValueList, ActionBar, LogView, JsonTree, Spinner, ErrorBoundary }; `; break; } const blob = new Blob([source], { type: "application/javascript" }); const url = URL.createObjectURL(blob); shimBlobUrls[specifier] = url; return url; } /** * Rewrite bare specifier imports in an ESM source string to use blob URLs. * * This handles the standard import patterns emitted by esbuild/rollup: * - `import { ... } from "react";` * - `import React from "react";` * - `import * as React from "react";` * - `import { ... } from "@paperclipai/plugin-sdk/ui";` * * Also handles re-exports: * - `export { ... } from "react";` */ function rewriteBareSpecifiers(source: string): string { // Build a mapping of bare specifiers to blob URLs. const rewrites: Record = { '"@paperclipai/plugin-sdk/ui"': `"${getShimBlobUrl("sdk-ui")}"`, "'@paperclipai/plugin-sdk/ui'": `'${getShimBlobUrl("sdk-ui")}'`, '"@paperclipai/plugin-sdk/ui/hooks"': `"${getShimBlobUrl("sdk-ui")}"`, "'@paperclipai/plugin-sdk/ui/hooks'": `'${getShimBlobUrl("sdk-ui")}'`, '"@paperclipai/plugin-sdk/ui/components"': `"${getShimBlobUrl("sdk-ui")}"`, "'@paperclipai/plugin-sdk/ui/components'": `'${getShimBlobUrl("sdk-ui")}'`, '"react/jsx-runtime"': `"${getShimBlobUrl("react/jsx-runtime")}"`, "'react/jsx-runtime'": `'${getShimBlobUrl("react/jsx-runtime")}'`, '"react-dom/client"': `"${getShimBlobUrl("react-dom/client")}"`, "'react-dom/client'": `'${getShimBlobUrl("react-dom/client")}'`, '"react-dom"': `"${getShimBlobUrl("react-dom")}"`, "'react-dom'": `'${getShimBlobUrl("react-dom")}'`, '"react"': `"${getShimBlobUrl("react")}"`, "'react'": `'${getShimBlobUrl("react")}'`, }; let result = source; for (const [from, to] of Object.entries(rewrites)) { // Only rewrite in import/export from contexts, not in arbitrary strings. // The regex matches `from "..."` or `from '...'` patterns. result = result.replaceAll(` from ${from}`, ` from ${to}`); // Also handle `import "..."` (side-effect imports) result = result.replaceAll(`import ${from}`, `import ${to}`); } return result; } /** * Fetch, rewrite, and import a plugin UI module. * * @param url - The URL to the plugin's UI entry module * @returns The module's exports */ async function importPluginModule(url: string): Promise> { // Check if the bridge registry is available. If not, fall back to direct // import (which will fail on bare specifiers but won't crash the loader). if (!globalThis.__paperclipPluginBridge__) { console.warn("[plugin-loader] Bridge registry not initialized, falling back to direct import"); return import(/* @vite-ignore */ url); } // Fetch the module source text const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch plugin module: ${response.status} ${response.statusText}`); } const source = await response.text(); // Rewrite bare specifier imports to blob URLs const rewritten = rewriteBareSpecifiers(source); // Create a blob URL from the rewritten source and import it const blob = new Blob([rewritten], { type: "application/javascript" }); const blobUrl = URL.createObjectURL(blob); try { const mod = await import(/* @vite-ignore */ blobUrl); return mod; } finally { // Clean up the blob URL after import (the module is already loaded) URL.revokeObjectURL(blobUrl); } } /** * Dynamically import a plugin's UI entry module and register all named * exports that look like React components (functions or classes) into the * component registry. * * This replaces the previous approach where plugin bundles had to * self-register via `window.paperclipPlugins.registerReactComponent()`. * Now the host is responsible for importing the module and binding * exports to the correct `pluginKey:exportName` registry keys. * * Plugin modules are loaded with bare-specifier rewriting so that imports * of `@paperclipai/plugin-sdk/ui`, `react`, and `react-dom` resolve to the * host-provided implementations via the bridge registry. * * Web-component registrations still work: if the module has a named export * that matches an `exportName` declared in a slot AND that export is a * string (the custom element tag name), it's registered as a web component. */ async function loadPluginModule(contribution: PluginUiContribution): Promise { const { pluginId, pluginKey, slots, launchers } = contribution; const moduleKey = buildPluginModuleKey(contribution); // Already loaded or loading — return early. const state = pluginLoadStates.get(moduleKey); if (state === "loaded" || state === "loading") { // If currently loading, wait for the inflight promise. const inflight = inflightImports.get(pluginId); if (inflight) await inflight; return; } // If another import for this plugin ID is currently in progress, wait for it. const running = inflightImports.get(pluginId); if (running) { await running; const recheckedState = pluginLoadStates.get(moduleKey); if (recheckedState === "loaded") { return; } } pluginLoadStates.set(moduleKey, "loading"); const url = buildPluginUiUrl(contribution); const importPromise = (async () => { try { // Dynamic ESM import of the plugin's UI entry module with // bare-specifier rewriting for host-provided dependencies. const mod: Record = await importPluginModule(url); // Collect the set of export names declared across all UI contributions so // we only register what the manifest advertises (ignore extra exports). const declaredExports = new Set(); for (const slot of slots) { declaredExports.add(slot.exportName); } for (const launcher of launchers) { if (launcher.exportName) { declaredExports.add(launcher.exportName); } if (isLauncherComponentTarget(launcher)) { declaredExports.add(launcher.action.target); } } for (const exportName of declaredExports) { const exported = mod[exportName]; if (exported === undefined) { console.warn( `Plugin "${pluginKey}" declares slot export "${exportName}" but the module does not export it.`, ); continue; } if (typeof exported === "function") { // React component (function component or class component). registerPluginReactComponent( pluginKey, exportName, exported as ComponentType, ); } else if (typeof exported === "string") { // Web component tag name. registerPluginWebComponent(pluginKey, exportName, exported); } else { console.warn( `Plugin "${pluginKey}" export "${exportName}" is neither a function nor a string tag name — skipping.`, ); } } pluginLoadStates.set(moduleKey, "loaded"); } catch (err) { pluginLoadStates.set(moduleKey, "error"); console.error(`Failed to load UI module for plugin "${pluginKey}"`, err); } finally { inflightImports.delete(pluginId); } })(); inflightImports.set(pluginId, importPromise); await importPromise; } function isLauncherComponentTarget(launcher: PluginLauncherDeclaration): boolean { return launcher.action.type === "openModal" || launcher.action.type === "openDrawer" || launcher.action.type === "openPopover"; } /** * Load UI modules for a set of plugin contributions. * * Returns a promise that resolves once all modules have been loaded (or * failed). Plugins that are already loaded are skipped. */ async function ensurePluginModulesLoaded(contributions: PluginUiContribution[]): Promise { await Promise.all( contributions.map((c) => loadPluginModule(c)), ); } export async function ensurePluginContributionLoaded( contribution: PluginUiContribution, ): Promise { await loadPluginModule(contribution); } /** * Returns the aggregate load state across a set of plugin contributions. * - If any plugin is still loading → "loading" * - If all are loaded (or no contributions) → "loaded" * - If all finished but some errored → "loaded" (errors are logged, not fatal) */ function aggregateLoadState(contributions: PluginUiContribution[]): "loading" | "loaded" { for (const c of contributions) { const state = pluginLoadStates.get(buildPluginModuleKey(c)); if (state === "loading" || state === "idle" || state === undefined) { return "loading"; } } return "loaded"; } // --------------------------------------------------------------------------- // React hooks // --------------------------------------------------------------------------- /** * Trigger dynamic loading of plugin UI modules when contributions change. * * This hook is intentionally decoupled from usePluginSlots so that callers * who consume slots via `usePluginSlots()` automatically get module loading * without extra wiring. */ function usePluginModuleLoader(contributions: PluginUiContribution[] | undefined) { const [, setTick] = useState(0); useEffect(() => { if (!contributions || contributions.length === 0) return; // Filter to contributions that haven't been loaded yet. const unloaded = contributions.filter((c) => { const state = pluginLoadStates.get(buildPluginModuleKey(c)); return state !== "loaded" && state !== "loading"; }); if (unloaded.length === 0) return; let cancelled = false; void ensurePluginModulesLoaded(unloaded).then(() => { // Re-render so the slot mount can resolve the newly-registered components. if (!cancelled) setTick((t) => t + 1); }); return () => { cancelled = true; }; }, [contributions]); } /** * Resolves and sorts slots across all ready plugin contributions. * * Filtering rules: * - `slotTypes` must match one of the caller-requested host slot types. * - Entity-scoped slot types (`detailTab`, `taskDetailView`, `contextMenuItem`) * require `entityType` and must include it in `slot.entityTypes`. * * Automatically triggers dynamic import of plugin UI modules for any * newly-discovered contributions. Components render once loading completes. */ export function usePluginSlots(filters: SlotFilters): UsePluginSlotsResult { const queryEnabled = filters.enabled ?? true; const { data, isLoading: isQueryLoading, error } = useQuery({ queryKey: queryKeys.plugins.uiContributions, queryFn: () => pluginsApi.listUiContributions(), enabled: queryEnabled, }); // Kick off dynamic imports for any new plugin contributions. usePluginModuleLoader(data); const slotTypesKey = useMemo(() => [...filters.slotTypes].sort().join("|"), [filters.slotTypes]); const slots = useMemo(() => { const allowedTypes = new Set(slotTypesKey.split("|").filter(Boolean) as PluginUiSlotType[]); const rows: ResolvedPluginSlot[] = []; for (const contribution of data ?? []) { for (const slot of contribution.slots) { if (!allowedTypes.has(slot.type)) continue; if (requiresEntityType(slot.type)) { if (!filters.entityType) continue; if (!slot.entityTypes?.includes(filters.entityType)) continue; } rows.push({ ...slot, pluginId: contribution.pluginId, pluginKey: contribution.pluginKey, pluginDisplayName: contribution.displayName, pluginVersion: contribution.version, }); } } rows.sort((a, b) => { const ao = a.order ?? Number.MAX_SAFE_INTEGER; const bo = b.order ?? Number.MAX_SAFE_INTEGER; if (ao !== bo) return ao - bo; const pluginCmp = a.pluginDisplayName.localeCompare(b.pluginDisplayName); if (pluginCmp !== 0) return pluginCmp; return a.displayName.localeCompare(b.displayName); }); return rows; }, [data, filters.entityType, slotTypesKey]); // Consider loading until both query and module imports are done. const modulesLoaded = data ? aggregateLoadState(data) === "loaded" : true; const isLoading = queryEnabled && (isQueryLoading || !modulesLoaded); return { slots, isLoading, errorMessage: error ? getErrorMessage(error) : null, }; } type PluginSlotErrorBoundaryProps = { slot: ResolvedPluginSlot; className?: string; children: ReactNode; }; type PluginSlotErrorBoundaryState = { hasError: boolean; }; class PluginSlotErrorBoundary extends Component { override state: PluginSlotErrorBoundaryState = { hasError: false }; static getDerivedStateFromError(): PluginSlotErrorBoundaryState { return { hasError: true }; } override componentDidCatch(error: unknown, info: ErrorInfo): void { // Keep plugin failures isolated while preserving actionable diagnostics. console.error("Plugin slot render failed", { pluginKey: this.props.slot.pluginKey, slotId: this.props.slot.id, error, info: info.componentStack, }); } override render() { if (this.state.hasError) { return (
{this.props.slot.pluginDisplayName}: failed to render
); } return this.props.children; } } function PluginWebComponentMount({ tagName, slot, context, className, }: { tagName: string; slot: ResolvedPluginSlot; context: PluginSlotContext; className?: string; }) { const ref = useRef(null); useEffect(() => { if (!ref.current) return; // Bridge manifest slot/context metadata onto the custom element instance. const el = ref.current as HTMLElement & { pluginSlot?: ResolvedPluginSlot; pluginContext?: PluginSlotContext; }; el.pluginSlot = slot; el.pluginContext = context; }, [context, slot]); return createElement(tagName, { ref, className }); } type PluginSlotMountProps = { slot: ResolvedPluginSlot; context: PluginSlotContext; className?: string; missingBehavior?: "hidden" | "placeholder"; }; /** * Maps the slot's `PluginSlotContext` to a `PluginHostContext` for the bridge. * * The bridge hooks need the full host context shape; the slot context carries * the subset available from the rendering location. */ function slotContextToHostContext( pluginSlotContext: PluginSlotContext, userId: string | null, ): PluginHostContext { return { companyId: pluginSlotContext.companyId ?? null, companyPrefix: pluginSlotContext.companyPrefix ?? null, projectId: pluginSlotContext.projectId ?? (pluginSlotContext.entityType === "project" ? pluginSlotContext.entityId ?? null : null), entityId: pluginSlotContext.entityId ?? null, entityType: pluginSlotContext.entityType ?? null, parentEntityId: pluginSlotContext.parentEntityId ?? null, userId, renderEnvironment: null, }; } /** * Wrapper component that sets the active bridge context around plugin renders. * * This ensures that `usePluginData()`, `usePluginAction()`, and `useHostContext()` * have access to the current plugin ID and host context during the render phase. */ function PluginBridgeScope({ pluginId, context, children, }: { pluginId: string; context: PluginSlotContext; children: ReactNode; }) { const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), }); const userId = session?.user?.id ?? session?.session?.userId ?? null; const hostContext = useMemo(() => slotContextToHostContext(context, userId), [context, userId]); const value = useMemo(() => ({ pluginId, hostContext }), [pluginId, hostContext]); return ( {children} ); } export function PluginSlotMount({ slot, context, className, missingBehavior = "hidden", }: PluginSlotMountProps) { const [, forceRerender] = useState(0); const component = resolveRegisteredComponent(slot); useEffect(() => { if (component) return; const inflight = inflightImports.get(slot.pluginId); if (!inflight) return; let cancelled = false; void inflight.finally(() => { if (!cancelled) { forceRerender((tick) => tick + 1); } }); return () => { cancelled = true; }; }, [component, slot.pluginId]); if (!component) { if (missingBehavior === "hidden") return null; return (
{slot.pluginDisplayName}: {slot.displayName}
); } if (component.kind === "react") { const node = createElement(component.component, { slot, context }); return ( {className ?
{node}
: node}
); } return ( ); } type PluginSlotOutletProps = { slotTypes: PluginUiSlotType[]; context: PluginSlotContext; entityType?: PluginUiSlotEntityType | null; className?: string; itemClassName?: string; errorClassName?: string; missingBehavior?: "hidden" | "placeholder"; }; export function PluginSlotOutlet({ slotTypes, context, entityType, className, itemClassName, errorClassName, missingBehavior = "hidden", }: PluginSlotOutletProps) { const { slots, errorMessage } = usePluginSlots({ slotTypes, entityType, companyId: context.companyId, }); if (errorMessage) { return (
Plugin extensions unavailable: {errorMessage}
); } if (slots.length === 0) return null; return (
{slots.map((slot) => ( ))}
); } // --------------------------------------------------------------------------- // Test helpers — exported for use in test suites only. // --------------------------------------------------------------------------- /** * Reset the module loader state. Only use in tests. * @internal */ export function _resetPluginModuleLoader(): void { pluginLoadStates.clear(); inflightImports.clear(); registry.clear(); if (typeof URL.revokeObjectURL === "function") { for (const url of Object.values(shimBlobUrls)) { URL.revokeObjectURL(url); } } for (const key of Object.keys(shimBlobUrls)) { delete shimBlobUrls[key]; } } export const _applyJsxRuntimeKeyForTests = applyJsxRuntimeKey; export const _rewriteBareSpecifiersForTests = rewriteBareSpecifiers;