import type { ReactiveController, ReactiveControllerHost } from "lit"; type Cleanup = () => void; type SourceEntry = { readonly getSource: () => T | null | undefined; readonly connect: (source: T) => Cleanup | undefined; readonly invalidateOnConnect: boolean; source: T | undefined; cleanup: Cleanup | undefined; generation: number; }; /** * Owns subscriptions whose sources can arrive or change after connection. * Source identity is checked before every render and all cleanup follows the * host lifecycle, so consumers do not need connected/updated retry loops. */ export class SubscriptionsController implements ReactiveController { private readonly entries: SourceEntry[] = []; private connected = false; constructor(private readonly host: ReactiveControllerHost) { host.addController(this); } watch( getSource: () => T | null | undefined, subscribe: (source: T, notify: () => void) => Cleanup, synchronize?: (source: T) => void, ): this { return this.addEntry( getSource, (source, entry) => { const generation = entry.generation; const notify = () => { if ( !this.connected || entry.generation !== generation || !Object.is(entry.source, source) ) { return; } synchronize?.(source); this.host.requestUpdate(); }; const cleanup = subscribe(source, notify); // Make cleanup visible before synchronization in case initial state // projection throws after the external listener is already registered. entry.cleanup = cleanup; synchronize?.(source); return cleanup; }, true, ); } effect( getSource: () => T | null | undefined, connect: (source: T) => Cleanup | undefined, ): this { return this.addEntry(getSource, (source) => connect(source), false); } hostConnected(): void { this.connected = true; this.refresh(true); } hostUpdate(): void { if (this.connected) { this.refresh(false); } } clear(): void { for (const entry of this.entries) { this.disconnectEntry(entry); } } hostDisconnected(): void { this.connected = false; this.clear(); } private addEntry( getSource: () => T | null | undefined, connect: (source: T, entry: SourceEntry) => Cleanup | undefined, invalidateOnConnect: boolean, ): this { const entry: SourceEntry = { getSource, connect: (source) => connect(source, entry), invalidateOnConnect, source: undefined, cleanup: undefined, generation: 0, }; this.entries.push(entry as SourceEntry); if (this.connected) { this.refreshEntry(entry, invalidateOnConnect); } return this; } private refresh(requestUpdate: boolean): void { for (const entry of this.entries) { this.refreshEntry(entry, requestUpdate && entry.invalidateOnConnect); } } private refreshEntry(entry: SourceEntry, requestUpdate: boolean): void { const source = entry.getSource() ?? undefined; if (Object.is(entry.source, source)) { return; } this.disconnectEntry(entry); if (source === undefined) { return; } entry.source = source; entry.generation += 1; try { entry.cleanup = entry.connect(source); } catch (error) { this.disconnectEntry(entry); throw error; } if (requestUpdate) { this.host.requestUpdate(); } } private disconnectEntry(entry: SourceEntry): void { entry.generation += 1; entry.source = undefined; const cleanup = entry.cleanup; entry.cleanup = undefined; if (!cleanup) { return; } try { cleanup(); } catch (error) { console.error("[openclaw] subscription cleanup failed", error); } } }