import { Component } from "/js/components/component.js"; import { effect } from "/js/signals.js"; const CONTEXT_PREFIX = "context-"; function kebabToCamel(name) { return name.replace(/-([a-z])/g, (_, char) => char.toUpperCase()); } class PluginSlot extends Component { connectedCallback() { if (this.initialized) return; this.initialized = true; if (!this.pluginService) { throw new Error("pluginService is required"); } // Only surfaces like plugin-posts-feed rendering actually read // interactionHandlers (see pluginRendering.js); slots with no post/ // profile interaction surface (e.g. the persistent overlay slot in // mainLayout.js) have no need to supply one. this.interactionHandlers ??= {}; this._pluginRoots = new Map(); this._currentRequest = null; this._subscribe(); } _subscribe() { this._disposeEffect?.(); const slotName = this.getAttribute("name"); if (!slotName) return; this._disposeEffect = effect(() => { this.pluginService.$slots.get(slotName); this._reconcile(); }, `plugin-slot[${slotName}]`); } disconnectedCallback() { if (!this.initialized) return; this._disposeEffect?.(); this._disposeEffect = null; this._currentRequest = null; this._pluginRoots.clear(); } // TODO - automatic? static get observedAttributes() { return ["name", "context-uri", "context-page", "context-notifications"]; } attributeChangedCallback(name, oldValue, newValue) { if (!this.initialized || oldValue === newValue) return; if (name === "name") { this._subscribe(); } else { this._reconcile(); } } _getContext() { const context = {}; for (const attr of this.attributes) { if (!attr.name.startsWith(CONTEXT_PREFIX)) continue; const key = kebabToCamel(attr.name.slice(CONTEXT_PREFIX.length)); context[key] = attr.value; } return context; } async _reconcile() { const slotName = this.getAttribute("name"); if (!slotName) return; const context = this._getContext(); const entries = this.pluginService.getSlotEntries(slotName); const requestToken = Symbol(); this._currentRequest = requestToken; // Drop cached roots for plugins no longer registered for this slot. const currentIds = new Set(entries.map((entry) => entry.pluginId)); for (const pluginId of [...this._pluginRoots.keys()]) { if (!currentIds.has(pluginId)) this._pluginRoots.delete(pluginId); } if (entries.length === 0) { this.replaceChildren(); return; } const results = await Promise.all( entries.map(async (entry) => { try { const node = await entry.invoke(context); return { entry, node }; } catch (error) { console.error( `Plugin "${entry.pluginId}" slot "${slotName}" failed:`, error, ); return { entry, node: null }; } }), ); if (this._currentRequest !== requestToken) return; const nextChildren = []; for (const { entry, node } of results) { if (!node) continue; let state = this._pluginRoots.get(entry.pluginId); if (!state) { const renderer = this.pluginService.getRenderer(entry.pluginId); state = { root: renderer.createRoot(), }; this._pluginRoots.set(entry.pluginId, state); } const element = state.root.render(node); nextChildren.push(element); } // Skip the DOM write when nothing actually changed. replaceChildren() // unconditionally detaches and reattaches every child per the DOM // spec's "replace all" algorithm, even when passed the exact same node // references already in place — which cancels any running CSS // animation/transition on them. Since state.root.render() patches // same-shape trees in place (see PluginRenderer._patch), nextChildren // is often referentially identical to the current children, and this // guard is what makes continuous animations (e.g. a plugin moving an // element via CSS transitions) actually stay continuous across // frequent reconciles (context changes, refreshSlot, etc.). const currentChildren = Array.from(this.childNodes); const changed = currentChildren.length !== nextChildren.length || currentChildren.some((child, i) => child !== nextChildren[i]); if (changed) { this.replaceChildren(...nextChildren); } } } PluginSlot.register();