Files
WRNexusJS/packages/reactive/src/signal.ts
T
2026-07-22 17:29:08 +05:30

159 lines
3.9 KiB
TypeScript

/**
* Fine-grained reactive primitives shared by server utilities and client code.
* Updates are synchronous by default and coalesced inside `batch()`.
*/
export type Subscriber<T> = (value: T, previous?: T) => void;
export type Unsubscribe = () => void;
export type Cleanup = () => void;
export interface Signal<T> {
get(): T;
set(next: T): void;
update(fn: (current: T) => T): void;
subscribe(fn: Subscriber<T>): Unsubscribe;
}
export interface ReadonlySignal<T> {
get(): T;
subscribe(fn: Subscriber<T>): Unsubscribe;
}
type DependencyCollector = (subscribe: (subscriber: Subscriber<unknown>) => 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<T>(fn: () => T): T {
batchDepth++;
try {
return fn();
} finally {
batchDepth--;
if (batchDepth === 0) flush();
}
}
/** Read reactive values without recording dependencies. */
export function untrack<T>(fn: () => T): T {
const previous = activeCollector;
activeCollector = null;
try {
return fn();
} finally {
activeCollector = previous;
}
}
export function signal<T>(initial: T): Signal<T> {
let value = initial;
let pendingPrevious: T | undefined;
let queued = false;
const subscribers = new Set<Subscriber<T>>();
const notify = (): void => {
queued = false;
const previous = pendingPrevious;
pendingPrevious = undefined;
for (const fn of [...subscribers]) fn(value, previous);
};
const api: Signal<T> = {
get(): T {
if (activeCollector) {
activeCollector((subscriber) => api.subscribe(subscriber as Subscriber<T>));
}
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<T>): 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<T>(read: () => T): ReadonlySignal<T> {
const output = signal<T>(undefined as T);
effect(() => output.set(read()));
return {
get: output.get,
subscribe: output.subscribe,
};
}