/** * Fine-grained reactive primitives shared by server utilities and client code. * Updates are synchronous by default and coalesced inside `batch()`. */ export type Subscriber = (value: T, previous?: T) => void; export type Unsubscribe = () => void; export type Cleanup = () => void; export interface Signal { get(): T; set(next: T): void; update(fn: (current: T) => T): void; subscribe(fn: Subscriber): Unsubscribe; } export interface ReadonlySignal { get(): T; subscribe(fn: Subscriber): Unsubscribe; } type DependencyCollector = (subscribe: (subscriber: Subscriber) => Unsubscribe) => void; let activeCollector: DependencyCollector | null = null; let batchDepth = 0; const pending = new Set<() => void>(); function enqueue(job: () => void): void { if (batchDepth > 0) pending.add(job); else job(); } function flush(): void { while (pending.size > 0) { const jobs = [...pending]; pending.clear(); for (const job of jobs) job(); } } /** Coalesce every signal notification made by `fn` into one flush. */ export function batch(fn: () => T): T { batchDepth++; try { return fn(); } finally { batchDepth--; if (batchDepth === 0) flush(); } } /** Read reactive values without recording dependencies. */ export function untrack(fn: () => T): T { const previous = activeCollector; activeCollector = null; try { return fn(); } finally { activeCollector = previous; } } export function signal(initial: T): Signal { let value = initial; let pendingPrevious: T | undefined; let queued = false; const subscribers = new Set>(); const notify = (): void => { queued = false; const previous = pendingPrevious; pendingPrevious = undefined; for (const fn of [...subscribers]) fn(value, previous); }; const api: Signal = { get(): T { if (activeCollector) { activeCollector((subscriber) => api.subscribe(subscriber as Subscriber)); } return value; }, set(next: T): void { if (Object.is(next, value)) return; const previous = value; value = next; if (!queued) { queued = true; pendingPrevious = previous; enqueue(notify); } }, update(fn: (current: T) => T): void { api.set(fn(value)); }, subscribe(fn: Subscriber): Unsubscribe { subscribers.add(fn); return () => subscribers.delete(fn); }, }; return api; } /** * Run a dependency-tracked side effect. Dependencies are rebuilt after every * execution, preventing stale subscriptions when conditional reads change. */ export function effect(run: () => void | Cleanup): Cleanup { let disposed = false; let cleanup: void | Cleanup; let subscriptions: Cleanup[] = []; let scheduled = false; const execute = (): void => { scheduled = false; if (disposed) return; if (typeof cleanup === "function") cleanup(); for (const unsubscribe of subscriptions) unsubscribe(); subscriptions = []; const previous = activeCollector; activeCollector = (subscribe) => { subscriptions.push( subscribe(() => { if (scheduled || disposed) return; scheduled = true; enqueue(execute); }), ); }; try { const nextCleanup = run(); cleanup = typeof nextCleanup === "function" ? nextCleanup : undefined; } finally { activeCollector = previous; } }; execute(); return () => { if (disposed) return; disposed = true; if (typeof cleanup === "function") cleanup(); for (const unsubscribe of subscriptions) unsubscribe(); subscriptions = []; }; } /** Create a lazily readable derived signal with automatic dependency tracking. */ export function computed(read: () => T): ReadonlySignal { const output = signal(undefined as T); effect(() => output.set(read())); return { get: output.get, subscribe: output.subscribe, }; }