'use client'; // Sandboxed slot mount. One iframe per (plugin, slot) - created lazily after // the background instance confirms `shouldShow(context)` (if defined). The // iframe renders the plugin's slot component using the plugin's bundle in a // null-origin context; its height is pushed back via postMessage and applied // to a wrapper
. import React, { useEffect, useMemo, useRef, useState } from 'react'; import type { SlotName } from '@/lib/plugin-types'; import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry'; import { createSlotInstance, type SandboxInstance } from '@/lib/plugin-sandbox/host-bridge'; import { slotPropsFor } from '@/lib/plugin-sandbox/permissions'; import { isInlineSlot } from '@/lib/plugin-sandbox/slot-layout'; import { snapshotHostTheme } from '@/lib/plugin-sandbox/host-theme'; import { useThemeStore } from '@/stores/theme-store'; interface Props { pluginId: string; slot: SlotName; extraProps?: Record; } export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) { const wrapperRef = useRef(null); const instanceRef = useRef(null); const [height, setHeight] = useState(0); const [width, setWidth] = useState(undefined); const inline = isInlineSlot(slot); // null = pending, true/false = decided const [show, setShow] = useState(null); // What the plugin may see of the surface the slot sits on. Everything the // plugin receives (shouldShow context, mount props, updates) goes through // this, so a ui:* permission never hands over mail, events or contacts. const props = useMemo(() => { const active = getActivePlugin(pluginId); return active ? slotPropsFor(active.plugin, slot, extraProps ?? {}) : {}; }, [pluginId, slot, extraProps]); // Decide whether to mount based on the plugin's shouldShow (background-side). useEffect(() => { const active = getActivePlugin(pluginId); if (!active) { setShow(false); return; } const offer = active.slotOffers.find((o) => o.name === slot); if (!offer) { setShow(false); return; } if (!offer.hasShouldShow) { setShow(true); return; } let cancelled = false; active.background .evaluateShouldShow(slot, props) .then((s) => { if (!cancelled) setShow(s); }) .catch(() => { if (!cancelled) setShow(false); }); return () => { cancelled = true; }; }, [pluginId, slot, props]); // Spawn / tear down the slot iframe. useEffect(() => { if (show !== true) return; const active = getActivePlugin(pluginId); if (!active || !wrapperRef.current) return; const locale = (globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ ?? 'en'; const inst = createSlotInstance({ plugin: active.plugin, slot, code: active.code, locale, tier: active.tier, extraProps: props, hostContainer: wrapperRef.current, onResize: (h, w) => { setHeight(h); if (w !== undefined) setWidth(w); }, }); instanceRef.current = inst; return () => { try { inst.destroy(); } catch { /* ignore */ } instanceRef.current = null; }; // We intentionally don't depend on props here - propagating prop // changes happens via postMessage below to avoid iframe churn. // eslint-disable-next-line react-hooks/exhaustive-deps }, [show, pluginId, slot]); // Push prop updates without remount. useEffect(() => { instanceRef.current?.updateProps(props); }, [props]); // Re-theme the live slot iframe when the host theme changes (dark/light // toggle or custom theme switch), without tearing down the iframe. Reacting // to resolvedTheme + activeThemeId covers both; the snapshot reads the // resolved DOM values so it picks up whichever is active. const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const activeThemeId = useThemeStore((s) => s.activeThemeId); useEffect(() => { instanceRef.current?.setTheme(snapshotHostTheme()); }, [resolvedTheme, activeThemeId]); if (show !== true) return null; return (
); }