@bodil/signal 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/signal.ts ADDED
@@ -0,0 +1,361 @@
1
+ import { AbortablePromise } from "@bodil/core/async";
2
+ import { toDisposable, type Disposifiable } from "@bodil/core/disposable";
3
+ import { Err, Ok, type Result } from "@bodil/opt";
4
+ import { Signal as SignalPolyfill } from "signal-polyfill";
5
+
6
+ import { ReadonlySignalArray, SignalArray } from "./array";
7
+ import { ReadonlySignalMap, SignalMap } from "./map";
8
+
9
+ const SystemSignal = ((globalThis as any).Signal as typeof SignalPolyfill) ?? SignalPolyfill;
10
+
11
+ /** @namespace */
12
+ export const subtle = SystemSignal.subtle;
13
+ // eslint-disable-next-line @typescript-eslint/no-namespace
14
+ export namespace subtle {
15
+ /** @hidden */
16
+ export type Watcher = SignalPolyfill.subtle.Watcher;
17
+ }
18
+
19
+ export type Options<A> = {
20
+ /** @internal */
21
+ [subtle.unwatched]?: () => void;
22
+ /** @internal */
23
+ [subtle.watched]?: () => void;
24
+ /**
25
+ * A function used to compare the new value of a signal with the previous
26
+ * value when the signal's value updates. If the function returns false,
27
+ * this will cause the signal's dependencies to update themselves with the
28
+ * new value, because the value is considered to have changed. If the
29
+ * function returns true, dependencies will not be updated, because no
30
+ * change is considered to have occurred.
31
+ *
32
+ * This option defaults to using {@link Object.is}.
33
+ */
34
+ equals?: (a: A, b: A) => boolean;
35
+ };
36
+
37
+ interface ISignal<A> {
38
+ /**
39
+ * The current value of the signal.
40
+ *
41
+ * When this is read inside a computation function or an effect function,
42
+ * this signal is automatically added to the effect or computed signal as a
43
+ * dependency.
44
+ */
45
+ readonly value: A;
46
+ /**
47
+ * Construct a {@link Signal.Computed} signal using a mapping function over
48
+ * the current value of this signal.
49
+ */
50
+ map<B>(fn: (value: A) => B): Computed<B>;
51
+ /**
52
+ * Subscribe to changes to the value of this signal.
53
+ */
54
+ on(callback: (value: A) => void): Disposable;
55
+ /**
56
+ * Get the current value of the signal.
57
+ *
58
+ * When this is called inside a computation function or an effect function,
59
+ * this signal is automatically added to the effect or computed signal as a
60
+ * dependency.
61
+ */
62
+ get(): A;
63
+ }
64
+
65
+ /**
66
+ * A signal which contains a writable value.
67
+ */
68
+ export class State<A> extends SystemSignal.State<A> implements ISignal<A> {
69
+ get value(): A {
70
+ return this.get();
71
+ }
72
+
73
+ set value(value: A) {
74
+ this.set(value);
75
+ }
76
+
77
+ get(): A {
78
+ return super.get();
79
+ }
80
+
81
+ /**
82
+ * Set the current value of the signal.
83
+ *
84
+ * This triggers an update of every dependency of the signal. Computed
85
+ * signals will recompute the next time they're read, and effects will be
86
+ * scheduled to run as soon as possible.
87
+ */
88
+ set(value: A): void {
89
+ super.set(value);
90
+ }
91
+
92
+ /**
93
+ * @internal
94
+ * @hidden
95
+ */
96
+ // eslint-disable-next-line @typescript-eslint/no-useless-constructor
97
+ constructor(value: A, options?: Options<A>) {
98
+ super(value, options);
99
+ }
100
+ /**
101
+ * Update the current value of this signal using a function.
102
+ */
103
+ update(fn: (value: A) => A): void {
104
+ this.set(SystemSignal.subtle.untrack(() => fn(this.get())));
105
+ }
106
+
107
+ /**
108
+ * Get a read only version of this signal.
109
+ */
110
+ readOnly(): Computed<A> {
111
+ return computed(() => this.get());
112
+ }
113
+
114
+ map<B>(fn: (value: A) => B): Computed<B> {
115
+ return computed(() => fn(this.get()));
116
+ }
117
+
118
+ on(callback: (value: A) => void): Disposable {
119
+ return subscribe(this, callback);
120
+ }
121
+
122
+ /**
123
+ * Test whether a value is a {@link Signal.State} signal.
124
+ */
125
+ static is(v: unknown): v is State<unknown> {
126
+ return v instanceof State;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * A signal which contains a reactive computation.
132
+ *
133
+ * @property get
134
+ * Get the current value of the signal.
135
+ *
136
+ * When this is called inside a computation function or an effect function,
137
+ * this signal is automatically added to the effect or computed signal as a
138
+ * dependency.
139
+ */
140
+ export class Computed<A> extends SystemSignal.Computed<A> implements ISignal<A> {
141
+ get value(): A {
142
+ return this.get();
143
+ }
144
+
145
+ get(): A {
146
+ return super.get();
147
+ }
148
+
149
+ /**
150
+ * @internal
151
+ * @hidden
152
+ */
153
+ // eslint-disable-next-line @typescript-eslint/no-useless-constructor
154
+ constructor(computeFn: () => A, options?: Options<A>) {
155
+ super(computeFn, options);
156
+ }
157
+
158
+ map<B>(fn: (value: A) => B): Computed<B> {
159
+ return computed(() => fn(this.get()));
160
+ }
161
+
162
+ on(callback: (value: A) => void): Disposable {
163
+ return subscribe(this, callback);
164
+ }
165
+
166
+ /**
167
+ * Test whether a value is a {@link Signal.Computed} signal.
168
+ */
169
+ static is(v: unknown): v is Computed<unknown> {
170
+ return v instanceof Computed;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * A type representing any kind of signal, either a {@link Signal.State} or a
176
+ * {@link Signal.Computed}.
177
+ */
178
+ export type Any<A> = State<A> | Computed<A>;
179
+
180
+ let effectNeedsEnqueue = true;
181
+ const effectWatcher = new SystemSignal.subtle.Watcher(() => {
182
+ if (effectNeedsEnqueue) {
183
+ effectNeedsEnqueue = false;
184
+ queueMicrotask(effectProcess);
185
+ }
186
+ });
187
+
188
+ function effectProcess(): void {
189
+ effectNeedsEnqueue = true;
190
+ for (const sig of effectWatcher.getPending()) {
191
+ sig.get();
192
+ }
193
+ effectWatcher.watch();
194
+ }
195
+
196
+ /**
197
+ * Test whether the given value is a signal.
198
+ *
199
+ * @see {@link Signal.State.is}, {@link Signal.Computed.is}
200
+ */
201
+ export function is(v: unknown): v is Any<unknown> {
202
+ return State.is(v) || Computed.is(v);
203
+ }
204
+
205
+ /**
206
+ * Construct a new {@link Signal.State} signal containing the provided value.
207
+ *
208
+ * @example
209
+ * const sig = Signal.state("Hello Joe!");
210
+ */
211
+ export function from<A>(value: A, options?: Options<A>): State<A> {
212
+ return new State(value, options);
213
+ }
214
+
215
+ /**
216
+ * {@inheritDoc from}
217
+ * @function
218
+ */
219
+ export const state = from;
220
+
221
+ /**
222
+ * Construct a new {@link Signal.Computed} signal using the provided
223
+ * computation function.
224
+ *
225
+ * @example
226
+ * const sig1 = Signal.from(2);
227
+ * const sig2 = Signal.from(3);
228
+ * const sum = Signal.computed(() => sig1.get() + sig2.get());
229
+ * assert(sum.get() === 5);
230
+ * sig1.set(4);
231
+ * assert(sum.get() === 7);
232
+ */
233
+ export function computed<A>(fn: (this: Computed<A>) => A, options?: Options<A>): Computed<A> {
234
+ return new Computed(fn, options);
235
+ }
236
+
237
+ /**
238
+ * Suscribe to a signal.
239
+ *
240
+ * The provided callback will be called every time the value of the
241
+ * signal changes.
242
+ */
243
+ export function subscribe<A>(signal: Any<A>, callback: (value: A) => void): Disposable {
244
+ return effect(() => callback(signal.value));
245
+ }
246
+
247
+ /**
248
+ * Create an effect responding to signal changes.
249
+ *
250
+ * The provided function will be called immediately, and again every
251
+ * time a signal that was read by the function changes.
252
+ *
253
+ * @example
254
+ * const sig = Signal.from("Hello Joe!");
255
+ * effect(() => console.log("Signal value is:", sig.get()));
256
+ * // prints "Signal value is: Hello Joe!"
257
+ * sig.set("Hello Mike!");
258
+ * // prints "Signal value is: Hello Mike!"
259
+ */
260
+ export function effect(fn: () => Disposifiable | void): Disposable {
261
+ let cleanup: Disposable | undefined;
262
+ const computed = new Computed(() => {
263
+ if (cleanup !== undefined) {
264
+ cleanup[Symbol.dispose]();
265
+ }
266
+ const result = fn();
267
+ cleanup = result !== undefined ? toDisposable(result) : undefined;
268
+ });
269
+ effectWatcher.watch(computed);
270
+ computed.get();
271
+ return toDisposable(() => {
272
+ effectWatcher.unwatch(computed);
273
+ if (cleanup !== undefined) {
274
+ cleanup[Symbol.dispose]();
275
+ }
276
+ });
277
+ }
278
+
279
+ /**
280
+ * Construct a new {@link Signal.Computed} signal using an async
281
+ * computation function.
282
+ *
283
+ * This returns a promise which will resolve to a
284
+ * {@link Signal.Computed} signal once the promise returned by the
285
+ * computation function resolves, and will update itself whenever
286
+ * subsequent calls to the computation function resolve.
287
+ *
288
+ * The function is provided with an {@link AbortSignal} which any async
289
+ * jobs started from it should abide by. If a signal dependency changes
290
+ * while the job is running, the {@link AbortSignal} will be triggered
291
+ * and the job restarted.
292
+ *
293
+ * @experimental
294
+ */
295
+ export function asyncComputed<A>(
296
+ fn: (abort: AbortSignal) => Promise<A>,
297
+ options?: Options<A>,
298
+ ): Promise<Computed<A>> {
299
+ const result = Promise.withResolvers<Computed<A>>();
300
+ const stream = computed(() =>
301
+ AbortablePromise.run<A>((resolve, reject, abort) => {
302
+ try {
303
+ fn(abort).then(resolve, reject);
304
+ } catch (e) {
305
+ reject(e as Error);
306
+ }
307
+ }),
308
+ );
309
+ const sig = new State<Result<A, Error>>(Err(new Error()));
310
+ let job: AbortablePromise<A> | undefined = undefined;
311
+ let resolved = false;
312
+ const resolve = () => {
313
+ if (!resolved) {
314
+ resolved = true;
315
+ result.resolve(computed(() => sig.get().unwrapExact(), options));
316
+ }
317
+ };
318
+ effect(() => {
319
+ if (job !== undefined) {
320
+ job.abort();
321
+ }
322
+ job = stream.get();
323
+ job.then(
324
+ (next) => {
325
+ sig.set(Ok(next));
326
+ resolve();
327
+ },
328
+ (error) => {
329
+ if (job?.signal.aborted === true) {
330
+ return;
331
+ }
332
+ sig.set(Err(error));
333
+ resolve();
334
+ },
335
+ );
336
+ });
337
+ return result.promise;
338
+ }
339
+
340
+ /**
341
+ * Create a new {@link Signal.Array} and optionally populate it from the
342
+ * provided {@link Iterable}.
343
+ */
344
+ export function array<A>(values?: Iterable<A>): SignalArray<A> {
345
+ return values === undefined ? new SignalArray() : SignalArray.from(values);
346
+ }
347
+
348
+ /**
349
+ * Create a new {@link Signal.Map} and optionally populate it from the
350
+ * provided {@link Iterable}.
351
+ */
352
+ export function map<K, V>(entries?: Iterable<[K, V]>): SignalMap<K, V> {
353
+ return new SignalMap(entries);
354
+ }
355
+
356
+ export {
357
+ SignalArray as Array,
358
+ ReadonlySignalArray as ReadonlyArray,
359
+ SignalMap as Map,
360
+ ReadonlySignalMap as ReadonlyMap,
361
+ };