1// Minimal fine-grained reactive signals — no dependencies. A `signal` holds a
2// value; reading it inside a tracking scope (an `effect` or `computed`)
3// subscribes that scope, and `set` re-runs the scopes that read it. Just enough
4// reactivity to let keypad faces re-render themselves when their data changes,
5// keeping app configs declarative:
6//
7// const bpm = signal(120);
8// reaper.on("transport", (t) => bpm.set(Math.round(t.tempo)));
9// keypad.key("up-right", () => stack(bpm(), "BPM"), increaseTempo);
10//
11// Reads during a scope are tracked as dependencies and cleared on each re-run,
12// so conditional reads don't leave stale subscriptions behind.
13
14export type Cleanup = () => void;
15
16interface Reaction {
17 run: () => void;
18 deps: Set<Set<Reaction>>;
19}
20
21let activeReaction: Reaction | null = null;
22
23export interface ReadonlySignal<T> {
24 (): T;
25 /** Read without subscribing the current scope. */
26 peek(): T;
27}
28
29export interface Signal<T> extends ReadonlySignal<T> {
30 set(value: T): void;
31 update(fn: (previous: T) => T): void;
32}
33
34/** A reactive value. Call it to read (and subscribe); `.set()` to write. */
35export function signal<T>(initial: T): Signal<T> {
36 let value = initial;
37 const subscribers = new Set<Reaction>();
38
39 const read = (() => {
40 if (activeReaction) {
41 subscribers.add(activeReaction);
42 activeReaction.deps.add(subscribers);
43 }
44 return value;
45 }) as Signal<T>;
46
47 read.peek = () => value;
48 read.set = (next: T) => {
49 if (Object.is(next, value)) return;
50 value = next;
51 // Copy first: a reaction may resubscribe (or unsubscribe) while running.
52 for (const reaction of [...subscribers]) reaction.run();
53 };
54 read.update = (fn) => read.set(fn(value));
55
56 return read;
57}
58
59function runReaction(reaction: Reaction, body: () => void) {
60 // Drop the previous run's subscriptions so stale dependencies don't linger.
61 for (const dep of reaction.deps) dep.delete(reaction);
62 reaction.deps.clear();
63 const previous = activeReaction;
64 activeReaction = reaction;
65 try {
66 body();
67 } finally {
68 activeReaction = previous;
69 }
70}
71
72/** Run `fn` now, and again whenever a signal it read changes. Returns a disposer. */
73export function effect(fn: () => void): Cleanup {
74 const reaction: Reaction = {
75 run: () => runReaction(reaction, fn),
76 deps: new Set(),
77 };
78 reaction.run();
79 return () => {
80 for (const dep of reaction.deps) dep.delete(reaction);
81 reaction.deps.clear();
82 };
83}
84
85/** A memoized derived value that recomputes when its dependencies change. */
86export function computed<T>(compute: () => T): ReadonlySignal<T> {
87 const holder = signal<T>(undefined as T);
88 let started = false;
89 const reaction: Reaction = {
90 run: () => runReaction(reaction, () => holder.set(compute())),
91 deps: new Set(),
92 };
93
94 const read = (() => {
95 if (!started) {
96 started = true;
97 reaction.run();
98 }
99 return holder();
100 }) as ReadonlySignal<T>;
101 read.peek = () => holder.peek();
102
103 return read;
104}