@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/array.ts CHANGED
@@ -1,164 +1,330 @@
1
+ import type { Equals } from "@bodil/core/types";
1
2
  import { None, Some, type Option } from "@bodil/opt";
2
3
 
3
- import { Signal } from ".";
4
+ import * as Signal from "./signal";
4
5
 
5
- export interface SignalReadonlyArray<A> extends Iterable<A> {
6
- isEmpty(): boolean;
7
- size(): number;
8
- indexOf(value: A, fromIndex?: number): number;
9
- findIndex(
10
- predicate: (value: A, index: number, obj: Array<A>) => boolean,
11
- thisArg?: any,
12
- ): number;
13
- toArray(): Array<A>;
14
- get(index: number): Option<A>;
15
- }
6
+ const instance = Symbol("Signal.Array");
16
7
 
17
- export class SignalArray<A> implements Iterable<A>, SignalReadonlyArray<A> {
18
- #array: Array<A>;
19
- readonly #signal = Signal<null>(null, { equals: () => false });
8
+ type InstanceProps<A> = {
9
+ array: Array<A>;
10
+ signal: Signal.State<null>;
11
+ equals?: (a: A, b: A) => boolean;
12
+ };
20
13
 
21
- static from<T>(iterable: Iterable<T>): SignalArray<T> {
22
- return new SignalArray(iterable);
23
- }
14
+ /**
15
+ * A read-only view of a {@link Signal.Array}.
16
+ *
17
+ * This acts just like the {@link Signal.Array} it was created from, mirroring
18
+ * the current contents of the source array, but without the ability to modify
19
+ * it.
20
+ */
21
+ export class ReadonlySignalArray<A> implements Iterable<A>, Equals {
22
+ /** @ignore */
23
+ protected readonly [instance]: InstanceProps<A>;
24
24
 
25
- constructor(iterable: Iterable<A>) {
26
- this.#array = [...iterable];
25
+ /** @ignore */
26
+ protected constructor(instanceProps: InstanceProps<A>) {
27
+ this[instance] = instanceProps;
27
28
  }
28
29
 
29
- [Symbol.iterator](): IteratorObject<A> {
30
- this.#signal.get();
31
- return Iterator.from(this.#array);
30
+ /**
31
+ * Test whether a value is a `Signal.ReadonlyArray`.
32
+ */
33
+ static is(value: unknown): value is Signal.ReadonlyArray<unknown> {
34
+ return value instanceof ReadonlySignalArray;
32
35
  }
33
36
 
37
+ /**
38
+ * Test whether the array is empty.
39
+ */
34
40
  isEmpty(): boolean {
35
- this.#signal.get();
36
- return this.#array.length === 0;
41
+ const self = this[instance];
42
+ self.signal.get();
43
+ return self.array.length === 0;
37
44
  }
38
45
 
46
+ /**
47
+ * Get the size of the array.
48
+ */
39
49
  size(): number {
40
- this.#signal.get();
41
- return this.#array.length;
50
+ const self = this[instance];
51
+ self.signal.get();
52
+ return self.array.length;
42
53
  }
43
54
 
55
+ /**
56
+ * Find the index at which the given value first occurs in the array,
57
+ * optionally starting at `fromIndex`. If the value doesn't occur in the
58
+ * array, return `-1`.
59
+ */
44
60
  indexOf(value: A, fromIndex?: number): number {
45
- this.#signal.get();
46
- return this.#array.indexOf(value, fromIndex);
61
+ const self = this[instance];
62
+ self.signal.get();
63
+ return self.array.indexOf(value, fromIndex);
47
64
  }
48
65
 
66
+ /**
67
+ * Find the first index in the array for which the `predicate` function
68
+ * returns true for the value at that index, or `-1` if it never does.
69
+ */
49
70
  findIndex(
50
71
  predicate: (value: A, index: number, obj: Array<A>) => boolean,
51
72
  thisArg?: any,
52
73
  ): number {
53
- this.#signal.get();
54
- return this.#array.findIndex(predicate, thisArg);
74
+ const self = this[instance];
75
+ self.signal.get();
76
+ return self.array.findIndex(predicate, thisArg);
55
77
  }
56
78
 
79
+ /**
80
+ * Test whether a value is an array with identical contents to this array.
81
+ */
82
+ equals(other: unknown): other is Signal.ReadonlyArray<A> {
83
+ if (!ReadonlySignalArray.is(other)) {
84
+ return false;
85
+ }
86
+ const self = this[instance];
87
+ self.signal.get();
88
+ return (
89
+ this.size() === other.size() &&
90
+ self.array.every((a, index) => Object.is(a, other.get(index).value))
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Copy the contents of this array into a normal {@link Array}.
96
+ */
57
97
  toArray(): Array<A> {
58
- this.#signal.get();
59
- return this.#array.slice();
98
+ const self = this[instance];
99
+ self.signal.get();
100
+ return self.array.slice();
60
101
  }
61
102
 
103
+ /**
104
+ * Get the value at the given index, or {@link None} if the index is out of
105
+ * bounds.
106
+ *
107
+ * @throws {@link TypeError} of `index` is not an integer
108
+ */
62
109
  get(index: number): Option<A> {
63
110
  if (!Number.isInteger(index)) {
64
111
  throw new TypeError(`Signal.Array.get: index ${index} is not an integer`);
65
112
  }
66
- this.#signal.get();
67
- return index >= 0 && this.#array.length > index ? Some(this.#array[index]) : None;
113
+ const self = this[instance];
114
+ self.signal.get();
115
+ return Object.hasOwn(this, index) ? Some(self.array[index]) : None;
68
116
  }
69
117
 
70
- readOnly(): SignalReadonlyArray<A> {
71
- return this;
118
+ /**
119
+ * Iterate over the contents of the array.
120
+ */
121
+ [Symbol.iterator](): IteratorObject<A> {
122
+ const self = this[instance];
123
+ self.signal.get();
124
+ return Iterator.from(self.array);
72
125
  }
126
+ }
73
127
 
74
- set(index: number, value: A) {
128
+ /**
129
+ * An array which behaves like a signal.
130
+ *
131
+ * Any read operation on the array, like {@link Signal.Array.get},
132
+ * {@link Signal.Array.size}, or iteration, acts like a
133
+ * {@link Signal.State.get}, registering the array as a dependency in any
134
+ * computed signals or effects it's used in. Correspondingly, any write
135
+ * operation to the array will cause all of these dependencies to update as
136
+ * with a {@link Signal.State.set}.
137
+ *
138
+ * Note that the API of this array is different from the built-in
139
+ * {@link Array}. This is intentional. Additionally, many operations you'd
140
+ * expect to find on an array, like {@link Array.map} or predicates like
141
+ * {@link Array.every}, are missing from the array itself. It's recommended
142
+ * that you access these through {@link Iterator}s instead.
143
+ *
144
+ * Note also that array access doesn't come with any granularity: if you read
145
+ * only a single index from the array inside a computed signal, *any* change to
146
+ * the array causes the signal to recompute, regardless of whether the value at
147
+ * the index you read changed.
148
+ */
149
+ export class SignalArray<A> extends ReadonlySignalArray<A> {
150
+ /**
151
+ * Construct a new array with the contents of the provided iterable.
152
+ */
153
+ static from<T>(iterable: Iterable<T>): Signal.Array<T> {
154
+ return new SignalArray(iterable);
155
+ }
156
+
157
+ /**
158
+ * Test whether a value is a `Signal.Array`.
159
+ */
160
+ static is(value: unknown): value is Signal.Array<unknown> {
161
+ return value instanceof SignalArray;
162
+ }
163
+
164
+ /**
165
+ * Construct a new array, optionally filling it with the contents of the
166
+ * provided iterable.
167
+ */
168
+ constructor(iterable: Iterable<A> = [], options?: Signal.Options<A>) {
169
+ super({
170
+ array: Array.from(iterable),
171
+ signal: new Signal.State<null>(null, { equals: () => false }),
172
+ equals: options?.equals,
173
+ });
174
+ }
175
+
176
+ /**
177
+ * Return a read-only view of the array.
178
+ */
179
+ readOnly(): Signal.ReadonlyArray<A> {
180
+ return new ReadonlySignalArray(this[instance]);
181
+ }
182
+
183
+ /**
184
+ * Write the given value to the given index of the array, overwriting
185
+ * anything that may have been there before.
186
+ *
187
+ * @throws {@link TypeError} if `index` is negative or not an integer
188
+ */
189
+ set(index: number, value: A): this {
75
190
  if (!Number.isInteger(index)) {
76
191
  throw new TypeError(`Signal.Array.set: index ${index} is not an integer`);
77
192
  }
78
- if (index > this.#array.length || index < 0) {
193
+ if (index < 0) {
79
194
  throw new RangeError(`Signal.Array.set: index ${index} out of bounds`);
80
195
  }
81
- this.#array[index] = value;
82
- this.#signal.set(null);
196
+ const self = this[instance];
197
+ if (!(self.equals ?? Object.is)(self.array[index], value)) {
198
+ self.array[index] = value;
199
+ self.signal.set(null);
200
+ }
201
+ return this;
83
202
  }
84
203
 
204
+ /**
205
+ * Append the provided values to the end of the array.
206
+ *
207
+ * @returns the number of values that were inserted.
208
+ */
85
209
  push(...values: Array<A>): number {
86
- const result = this.#array.push(...values);
87
- this.#signal.set(null);
210
+ const self = this[instance];
211
+ const result = self.array.push(...values);
212
+ self.signal.set(null);
88
213
  return result;
89
214
  }
90
215
 
216
+ /**
217
+ * Prepend the provided values to the start of the array.
218
+ *
219
+ * @returns the number of values that were inserted.
220
+ */
91
221
  unshift(...values: Array<A>): number {
92
- const result = this.#array.unshift(...values);
93
- this.#signal.set(null);
222
+ const self = this[instance];
223
+ const result = self.array.unshift(...values);
224
+ self.signal.set(null);
94
225
  return result;
95
226
  }
96
227
 
228
+ /**
229
+ * Remove the item at the front of the array and return it.
230
+ */
97
231
  pop(): Option<A> {
98
232
  if (this.isEmpty()) {
99
233
  return None;
100
234
  }
101
- const result = this.#array.pop()!;
102
- this.#signal.set(null);
235
+ const self = this[instance];
236
+ const result = self.array.pop()!;
237
+ self.signal.set(null);
103
238
  return Some(result);
104
239
  }
105
240
 
241
+ /**
242
+ * Remove the item at the end of the array and return it.
243
+ */
106
244
  shift(): Option<A> {
107
245
  if (this.isEmpty()) {
108
246
  return None;
109
247
  }
110
- const result = this.#array.shift()!;
111
- this.#signal.set(null);
248
+ const self = this[instance];
249
+ const result = self.array.shift()!;
250
+ self.signal.set(null);
112
251
  return Some(result);
113
252
  }
114
253
 
115
- splice(start: number, deleteCount: number, ...values: Array<A>): Array<A> {
254
+ /** @see {@link Array.splice} */
255
+ splice(start: number, deleteCount?: number, ...values: Array<A>): Array<A> {
116
256
  if (!Number.isInteger(start)) {
117
- throw new TypeError(`Signal.Array.splice: index ${start} is not an integer`);
257
+ throw new TypeError(`Signal.Array.splice: start ${start} is not an integer`);
118
258
  }
119
- if (start > this.#array.length || start < 0) {
120
- throw new RangeError(`Signal.Array.splice: index ${start} out of bounds`);
259
+ if (deleteCount !== undefined && !Number.isInteger(deleteCount)) {
260
+ throw new TypeError(
261
+ `Signal.Array.splice: deleteCount ${deleteCount} is not an integer`,
262
+ );
121
263
  }
122
- const result = this.#array.splice(start, deleteCount, ...values);
123
- this.#signal.set(null);
264
+ const self = this[instance];
265
+ const result = self.array.splice(start, deleteCount as any, ...values);
266
+ self.signal.set(null);
124
267
  return result;
125
268
  }
126
269
 
270
+ /**
271
+ * Insert the given values starting at the given index, shifting the
272
+ * remainder of the array to higher indices accordingly.
273
+ *
274
+ * @throws {@link TypeError} if `index` is not an integer
275
+ */
127
276
  insert(index: number, ...values: Array<A>): this {
128
277
  if (!Number.isInteger(index)) {
129
278
  throw new TypeError(`Signal.Array.insert: index ${index} is not an integer`);
130
279
  }
131
- if (index > this.#array.length || index < 0) {
132
- throw new RangeError(`Signal.Array.insert: index ${index} out of bounds`);
133
- }
134
- this.#array.splice(index, 0, ...values);
135
- this.#signal.set(null);
280
+ this.splice(index, 0, ...values);
136
281
  return this;
137
282
  }
138
283
 
284
+ /**
285
+ * Remove the value at the given index and return it. If there's no value at
286
+ * the given index, return {@link None}.
287
+ *
288
+ * @throws {@link TypeError} if `index` is not an integer
289
+ */
139
290
  removeIndex(index: number): Option<A> {
140
291
  if (!Number.isInteger(index)) {
141
292
  throw new TypeError(`Signal.Array.removeIndex: index ${index} is not an integer`);
142
293
  }
143
- if (index < 0 || index >= this.#array.length) {
294
+ if (!Object.hasOwn(this, index)) {
144
295
  return None;
145
296
  }
146
- const removed = this.#array.splice(index, 1).pop()!;
147
- this.#signal.set(null);
297
+ const self = this[instance];
298
+ const removed = self.array.splice(index, 1).pop()!;
299
+ self.signal.set(null);
148
300
  return Some(removed);
149
301
  }
150
302
 
303
+ /**
304
+ * Remove the first occurrence of the given value from the array, if it
305
+ * exists in the array.
306
+ */
151
307
  remove(value: A): Option<A> {
152
- return this.removeIndex(this.indexOf(value));
308
+ const index = this.indexOf(value);
309
+ return index < 0 ? None : this.removeIndex(index);
153
310
  }
154
311
 
312
+ /**
313
+ * Remove the first value from the array that matches the given predicate
314
+ * function, if any.
315
+ */
155
316
  removeFn(predicate: (value: A, index: number, obj: Array<A>) => boolean): Option<A> {
156
- return this.removeIndex(this.findIndex(predicate as () => boolean));
317
+ const index = this.findIndex(predicate as () => boolean);
318
+ return index < 0 ? None : this.removeIndex(index);
157
319
  }
158
320
 
321
+ /**
322
+ * Discard all values in the array, leaving an empty array.
323
+ */
159
324
  clear(): this {
160
- this.#array = [];
161
- this.#signal.set(null);
325
+ const self = this[instance];
326
+ self.array = [];
327
+ self.signal.set(null);
162
328
  return this;
163
329
  }
164
330
  }
package/src/index.ts CHANGED
@@ -4,268 +4,4 @@
4
4
  * @module
5
5
  */
6
6
 
7
- import { AbortablePromise } from "@bodil/core/async";
8
- import { toDisposable, type Disposifiable } from "@bodil/core/disposable";
9
- import { Err, Ok, type Result } from "@bodil/opt";
10
- import { Signal } from "signal-polyfill";
11
-
12
- import { SignalArray } from "./array";
13
-
14
- interface ISignal<A> {
15
- /**
16
- * The current value of the signal.
17
- *
18
- * When this is read inside a computation function or an effect function,
19
- * this signal is automatically added to the effect or computed signal as a
20
- * dependency.
21
- */
22
- readonly value: A;
23
- /**
24
- * Construct a {@link Signal.Computed} signal using a mapping function over
25
- * the current value of this signal.
26
- */
27
- map<B>(fn: (value: A) => B): SignalGlobal.Computed<B>;
28
- /**
29
- * Subscribe to changes to the value of this signal.
30
- */
31
- on(callback: (value: A) => void): Disposable;
32
- }
33
-
34
- /**
35
- * A writable state signal.
36
- */
37
- class StateSignal<A> extends Signal.State<A> implements ISignal<A> {
38
- get value(): A {
39
- return this.get();
40
- }
41
-
42
- set value(value: A) {
43
- this.set(value);
44
- }
45
-
46
- /**
47
- * Update the current value of this signal using a function.
48
- */
49
- update(fn: (value: A) => A): void {
50
- this.set(Signal.subtle.untrack(() => fn(this.get())));
51
- }
52
-
53
- /**
54
- * Get a read only version of this signal.
55
- */
56
- readOnly(): SignalGlobal.Computed<A> {
57
- return SignalGlobal.computed(() => this.get());
58
- }
59
-
60
- map<B>(fn: (value: A) => B): SignalGlobal.Computed<B> {
61
- return SignalGlobal.computed(() => fn(this.get()));
62
- }
63
-
64
- on(callback: (value: A) => void): Disposable {
65
- return SignalGlobal.subscribe(this, callback);
66
- }
67
-
68
- static is(v: unknown): v is SignalGlobal.State<unknown> {
69
- return v instanceof StateSignal;
70
- }
71
- }
72
-
73
- /**
74
- * A read only signal computed from the values of other signals.
75
- */
76
- class ComputedSignal<A> extends Signal.Computed<A> implements ISignal<A> {
77
- get value(): A {
78
- return this.get();
79
- }
80
-
81
- map<B>(fn: (value: A) => B): SignalGlobal.Computed<B> {
82
- return SignalGlobal.computed(() => fn(this.get()));
83
- }
84
-
85
- on(callback: (value: A) => void): Disposable {
86
- return SignalGlobal.subscribe(this, callback);
87
- }
88
-
89
- static is(v: unknown): v is SignalGlobal.Computed<unknown> {
90
- return v instanceof ComputedSignal;
91
- }
92
- }
93
-
94
- let effectNeedsEnqueue = true;
95
- const effectWatcher = new Signal.subtle.Watcher(() => {
96
- if (effectNeedsEnqueue) {
97
- effectNeedsEnqueue = false;
98
- queueMicrotask(effectProcess);
99
- }
100
- });
101
-
102
- function effectProcess(): void {
103
- effectNeedsEnqueue = true;
104
- for (const sig of effectWatcher.getPending()) {
105
- sig.get();
106
- }
107
- effectWatcher.watch();
108
- }
109
-
110
- type SignalGlobal<A> = SignalGlobal.State<A> | SignalGlobal.Computed<A>;
111
-
112
- const SignalGlobal = Object.assign(
113
- /**
114
- * Construct a new {@link Signal.State} signal containing the provided value.
115
- *
116
- * @example
117
- * const sig = Signal("Hello Joe!");
118
- */
119
- function <A>(value: A, options?: Signal.Options<A>): SignalGlobal.State<A> {
120
- return new SignalGlobal.State(value, options);
121
- },
122
- {
123
- /**
124
- * Test whether the given value is a signal.
125
- */
126
- is(v: unknown): v is SignalGlobal<unknown> {
127
- return SignalGlobal.State.is(v) || SignalGlobal.Computed.is(v);
128
- },
129
-
130
- /**
131
- * Construct a new {@link Signal.Computed} signal using the provided
132
- * computation function.
133
- *
134
- * @example
135
- * const sig1 = Signal(2);
136
- * const sig2 = Signal(3);
137
- * const sum = Signal.computed(() => sig1.get() + sig2.get());
138
- * assert(sum.get() === 5);
139
- */
140
- computed<A>(
141
- fn: (this: SignalGlobal.Computed<A>) => A,
142
- options?: Signal.Options<A>,
143
- ): SignalGlobal.Computed<A> {
144
- return new SignalGlobal.Computed(fn, options);
145
- },
146
-
147
- /**
148
- * Suscribe to a signal.
149
- *
150
- * The provided callback will be called every time the value of the
151
- * signal changes.
152
- */
153
- subscribe<A>(signal: SignalGlobal<A>, callback: (value: A) => void): Disposable {
154
- return SignalGlobal.effect(() => callback(signal.value));
155
- },
156
-
157
- /**
158
- * Create an effect responding to signal changes.
159
- *
160
- * The provided function will be called immediately, and again every
161
- * time a signal that was read by the function changes.
162
- *
163
- * @example
164
- * const sig = Signal("Hello Joe!");
165
- * effect(() => console.log("Signal value is:", sig.get()));
166
- * // prints "Signal value is: Hello Joe!"
167
- * sig.set("Hello Mike!");
168
- * // prints "Signal value is: Hello Mike!"
169
- */
170
- effect(fn: () => Disposifiable | void): Disposable {
171
- let cleanup: Disposable | undefined;
172
- const computed = new SignalGlobal.Computed(() => {
173
- if (cleanup !== undefined) {
174
- cleanup[Symbol.dispose]();
175
- }
176
- const result = fn();
177
- cleanup = result !== undefined ? toDisposable(result) : undefined;
178
- });
179
- effectWatcher.watch(computed);
180
- computed.get();
181
- return toDisposable(() => {
182
- effectWatcher.unwatch(computed);
183
- if (cleanup !== undefined) {
184
- cleanup[Symbol.dispose]();
185
- }
186
- });
187
- },
188
-
189
- /**
190
- * Construct a new {@link Signal.Computed} signal using an async
191
- * computation function.
192
- *
193
- * This returns a promise which will resolve to a
194
- * {@link Signal.Computed} signal once the promise returned by the
195
- * computation function resolves, and will update itself whenever
196
- * subsequent calls to the computation function resolve.
197
- *
198
- * The function is provided with an {@link AbortSignal} which any async
199
- * jobs started from it should abide by. If a signal dependency changes
200
- * while the job is running, the {@link AbortSignal} will be triggered
201
- * and the job restarted.
202
- */
203
- asyncComputed<A>(
204
- fn: (abort: AbortSignal) => Promise<A>,
205
- options?: Signal.Options<A>,
206
- ): Promise<SignalGlobal.Computed<A>> {
207
- const result = Promise.withResolvers<SignalGlobal.Computed<A>>();
208
- const stream = SignalGlobal.computed(() =>
209
- AbortablePromise.run<A>((resolve, reject, abort) => {
210
- try {
211
- fn(abort).then(resolve, reject);
212
- } catch (e) {
213
- reject(e as Error);
214
- }
215
- }),
216
- );
217
- const sig: SignalGlobal.State<Result<A, Error>> = SignalGlobal(Err(new Error()));
218
- let job: AbortablePromise<A> | undefined = undefined;
219
- let resolved = false;
220
- const resolve = () => {
221
- if (!resolved) {
222
- resolved = true;
223
- result.resolve(SignalGlobal.computed(() => sig.get().unwrapExact(), options));
224
- }
225
- };
226
- SignalGlobal.effect(() => {
227
- if (job !== undefined) {
228
- job.abort();
229
- }
230
- job = stream.get();
231
- job.then(
232
- (next) => {
233
- sig.set(Ok(next));
234
- resolve();
235
- },
236
- (error) => {
237
- if (job?.signal.aborted === true) {
238
- return;
239
- }
240
- sig.set(Err(error));
241
- resolve();
242
- },
243
- );
244
- });
245
- return result.promise;
246
- },
247
-
248
- array<A>(values: Iterable<A>) {
249
- return SignalArray.from(values);
250
- },
251
-
252
- State: StateSignal,
253
- Computed: ComputedSignal,
254
- Array: SignalArray,
255
- subtle: Signal.subtle,
256
- },
257
- );
258
-
259
- declare namespace SignalGlobal {
260
- /** @interface */
261
- export type State<A> = StateSignal<A>;
262
- /** @interface */
263
- export type Computed<A> = ComputedSignal<A>;
264
- export type Array<A> = SignalArray<A>;
265
- export type Options<A> = Signal.Options<A>;
266
- export namespace subtle {
267
- export type Watcher = Signal.subtle.Watcher;
268
- }
269
- }
270
-
271
- export { SignalGlobal as Signal };
7
+ export * as Signal from "./signal";