import { notifySubscribers } from '@deepseek-ai/dsh-client-store' /** * Batches structural updates in microtasks and stream updates by animation * frame. Reads may rebuild a dirty snapshot without consuming the pending * subscriber notification. */ export class Notifier { private listeners = new Set<() => void>() private dirty = false private notifyPending = false private scheduled: 'none' | 'microtask' | 'frame' = 'none' private scheduleGeneration = 0 /** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */ constructor(private readonly rebuild: () => void) {} /** * uSES subscription entry. * @param listener - change callback. * @returns the unsubscribe function. */ subscribe(listener: () => void): () => void { this.listeners.add(listener) return () => { this.listeners.delete(listener) } } /** Mark the snapshot dirty and notify in a microtask. */ markDirty(): void { this.dirty = true this.notifyPending = true if (this.scheduled === 'microtask') return this.schedule('microtask') } /** Mark the snapshot dirty and publish cumulative state at most once per frame. */ markFrameDirty(): void { this.dirty = true this.notifyPending = true if (this.scheduled !== 'none') return this.schedule(typeof globalThis.requestAnimationFrame === 'function' ? 'frame' : 'microtask') } /** * Synchronous flush: controlled-input writes must notify in the same tick as * onChange, or React rolls the DOM back to the stale value and the caret jumps to the end. */ notifyNow(): void { this.dirty = true this.notifyPending = true this.invalidateSchedule() this.flush() } /** * Pre-getSnapshot check: rebuild synchronously when dirty (read path * before first subscribe / while unobserved). Notification stays pending. */ ensureFresh(): void { if (!this.dirty) return this.dirty = false this.rebuild() } private schedule(kind: 'microtask' | 'frame'): void { const generation = ++this.scheduleGeneration this.scheduled = kind const publish = () => { if (generation !== this.scheduleGeneration) return this.scheduled = 'none' this.flush() } if (kind === 'frame') { globalThis.requestAnimationFrame(publish) } else { queueMicrotask(publish) } } private invalidateSchedule(): void { this.scheduleGeneration++ this.scheduled = 'none' } private flush(): void { if (!this.notifyPending) return if (this.listeners.size === 0) return // lazy: dirty (if still set) rebuilds on next getSnapshot this.notifyPending = false if (this.dirty) { this.dirty = false this.rebuild() } notifySubscribers(this.listeners, '[session-controller]') } }