import { effect, signal, type Cleanup, type ReadonlySignal } from "./signal.ts"; export interface WatchOptions { immediate?: boolean; equals?: (left: T, right: T) => boolean; } export function watch( read: () => T, listener: (value: T, previous: T | undefined) => void | Cleanup, options: WatchOptions = {}, ): Cleanup { const equals = options.equals ?? Object.is; let initialized = false; let previous: T | undefined; let listenerCleanup: void | Cleanup; const dispose = effect(() => { const value = read(); const old = previous; if (!initialized) { initialized = true; if (!options.immediate) { previous = value; return; } } else if (equals(value, previous as T)) { return; } if (typeof listenerCleanup === "function") listenerCleanup(); previous = value; listenerCleanup = listener(value, old); }); return () => { dispose(); if (typeof listenerCleanup === "function") listenerCleanup(); }; } export type ResourceStatus = "idle" | "pending" | "success" | "error"; export interface Resource { data: ReadonlySignal; error: ReadonlySignal; status: ReadonlySignal; loading: ReadonlySignal; run(): Promise; abort(reason?: unknown): void; reset(): void; } export interface ResourceOptions { initial?: T; immediate?: boolean; keepPrevious?: boolean; } export function resource( loader: (signal: AbortSignal) => Promise, options: ResourceOptions = {}, ): Resource { const data = signal(options.initial); const error = signal(undefined); const status = signal("idle"); let controller: AbortController | null = null; let generation = 0; const api: Resource = { data, error, status, loading: { get: () => status.get() === "pending", subscribe(fn) { return status.subscribe((value, previous) => fn(value === "pending", previous === "pending"), ); }, }, async run() { controller?.abort("superseded"); const requestController = new AbortController(); controller = requestController; const current = ++generation; if (!options.keepPrevious) data.set(options.initial); error.set(undefined); status.set("pending"); try { const value = await loader(requestController.signal); if (current !== generation || requestController.signal.aborted) { return undefined; } controller = null; data.set(value); status.set("success"); return value; } catch (cause) { if (current !== generation || requestController.signal.aborted) { return undefined; } controller = null; error.set(cause); status.set("error"); return undefined; } }, abort(reason) { generation++; controller?.abort(reason); controller = null; if (status.get() === "pending") status.set("idle"); }, reset() { api.abort("reset"); data.set(options.initial); error.set(undefined); status.set("idle"); }, }; if (options.immediate) void api.run(); return api; } export interface ReactiveScope { add(cleanup: Cleanup): Cleanup; dispose(): void; readonly disposed: boolean; } export function createScope(): ReactiveScope { const cleanups = new Set(); let disposed = false; return { add(cleanup) { if (disposed) { cleanup(); return cleanup; } cleanups.add(cleanup); return () => { if (cleanups.delete(cleanup)) cleanup(); }; }, dispose() { if (disposed) return; disposed = true; for (const cleanup of [...cleanups].reverse()) cleanup(); cleanups.clear(); }, get disposed() { return disposed; }, }; }