/** * Resolved light/dark theme for the plugin host (`host.theme` / * `host.onThemeChange`). * * `AppThemeProvider` (components/theme/app-theme.tsx) applies the resolved * theme by toggling the `light`/`dark` class on ``, and emits no event * of its own. Observing that class is therefore the only source that covers * every way the theme can flip — the settings picker, a live preview hover, * and an OS-level `prefers-color-scheme` change while the app sits on * "system" — without the plugin host having to subscribe to React context it * is mounted outside of. */ export type ResolvedTheme = "light" | "dark"; /** Reads the resolved light/dark theme currently applied to the document. */ export function readResolvedTheme(doc: Document = document): ResolvedTheme { return doc.documentElement.classList.contains("dark") ? "dark" : "light"; } type ThemeListener = (theme: ResolvedTheme) => void; const listeners = new Set(); let observer: MutationObserver | null = null; let lastTheme: ResolvedTheme | null = null; /** * Fans a class mutation out to subscribers, but only when the *resolved* * theme actually changed. ``'s class list carries far more than the * theme (Tailwind state classes, the rendering-engine marker, transition * suppression), so an unfiltered observer would wake every plugin on * unrelated churn — expensive for the canvas repaints this exists to drive. * * Never throws. These listeners are third-party plugin callbacks and this is * a cross-plugin fan-out, so one throwing listener must not starve the ones * after it — the same isolation `dispatchToPluginWsHandlers` and * `PluginErrorBoundary` already give every other plugin extension point. * Without it the damage outlives the throw: `lastTheme` has already advanced, * so the equality guard above means a repeat mutation to the same theme is a * no-op and the skipped listeners stay stale until some *different* flip * happens. */ function handleMutation(): void { const next = readResolvedTheme(); if (next === lastTheme) return; lastTheme = next; for (const listener of [...listeners]) { try { listener(next); } catch (error) { console.error("[plugins] theme change listener threw:", error); } } } /** * Subscribes to resolved-theme changes; returns an unsubscribe function. * * One `MutationObserver` is shared across every subscriber and is connected * lazily on the first subscription / disconnected after the last one, so a * page with no plugins (or none that care about the theme) observes nothing. * * That shared observer and its `listeners`/`lastTheme` state are module * scope — correct for a browser (one document, one theme), but it means a * test that subscribes without unsubscribing leaks a live observer into every * later test in the file. Always call the returned function in cleanup. */ export function subscribeToThemeChanges(listener: ThemeListener): () => void { listeners.add(listener); if (!observer) { lastTheme = readResolvedTheme(); observer = new MutationObserver(handleMutation); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"], }); } return () => { listeners.delete(listener); if (listeners.size === 0 && observer) { observer.disconnect(); observer = null; lastTheme = null; } }; }