863 lines
28 KiB
TypeScript
863 lines
28 KiB
TypeScript
/**
|
|
* @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<PluginSlotComponentProps>;
|
|
}
|
|
| {
|
|
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<string, RegisteredPluginComponent>();
|
|
|
|
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<PluginSlotComponentProps>,
|
|
): 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<string, PluginLoadState>();
|
|
|
|
/**
|
|
* Promise cache to prevent concurrent duplicate imports for the same plugin.
|
|
*/
|
|
const inflightImports = new Map<string, Promise<void>>();
|
|
|
|
/**
|
|
* 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<string, string> = {};
|
|
|
|
function applyJsxRuntimeKey(
|
|
props: Record<string, unknown> | null | undefined,
|
|
key: string | number | undefined,
|
|
): Record<string, unknown> {
|
|
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<string, string> = {
|
|
'"@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<Record<string, unknown>> {
|
|
// 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<void> {
|
|
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<string, unknown> = 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<string>();
|
|
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<PluginSlotComponentProps>,
|
|
);
|
|
} 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<void> {
|
|
await Promise.all(
|
|
contributions.map((c) => loadPluginModule(c)),
|
|
);
|
|
}
|
|
|
|
export async function ensurePluginContributionLoaded(
|
|
contribution: PluginUiContribution,
|
|
): Promise<void> {
|
|
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<PluginSlotErrorBoundaryProps, PluginSlotErrorBoundaryState> {
|
|
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 (
|
|
<div className={cn("rounded-md border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive", this.props.className)}>
|
|
{this.props.slot.pluginDisplayName}: failed to render
|
|
</div>
|
|
);
|
|
}
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
function PluginWebComponentMount({
|
|
tagName,
|
|
slot,
|
|
context,
|
|
className,
|
|
}: {
|
|
tagName: string;
|
|
slot: ResolvedPluginSlot;
|
|
context: PluginSlotContext;
|
|
className?: string;
|
|
}) {
|
|
const ref = useRef<HTMLElement | null>(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 (
|
|
<PluginBridgeContext.Provider value={value}>
|
|
{children}
|
|
</PluginBridgeContext.Provider>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className={cn("rounded-md border border-dashed border-border px-2 py-1 text-xs text-muted-foreground", className)}>
|
|
{slot.pluginDisplayName}: {slot.displayName}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (component.kind === "react") {
|
|
const node = createElement(component.component, { slot, context });
|
|
return (
|
|
<PluginSlotErrorBoundary slot={slot} className={className}>
|
|
<PluginBridgeScope pluginId={slot.pluginId} context={context}>
|
|
{className ? <div className={className}>{node}</div> : node}
|
|
</PluginBridgeScope>
|
|
</PluginSlotErrorBoundary>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PluginSlotErrorBoundary slot={slot} className={className}>
|
|
<PluginWebComponentMount
|
|
tagName={component.tagName}
|
|
slot={slot}
|
|
context={context}
|
|
className={className}
|
|
/>
|
|
</PluginSlotErrorBoundary>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className={cn("rounded-md border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive", errorClassName)}>
|
|
Plugin extensions unavailable: {errorMessage}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (slots.length === 0) return null;
|
|
|
|
return (
|
|
<div className={className}>
|
|
{slots.map((slot) => (
|
|
<PluginSlotMount
|
|
key={`${slot.pluginKey}:${slot.id}`}
|
|
slot={slot}
|
|
context={context}
|
|
className={itemClassName}
|
|
missingBehavior={missingBehavior}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|