Files
2026-08-08 02:12:44 +02:00

87 lines
3.3 KiB
TypeScript

/**
* 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 `<html>`, 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<ThemeListener>();
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. `<html>`'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;
}
};
}