159 lines
3.9 KiB
TypeScript
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,
|
|
};
|
|
}
|