@bodil/signal 0.3.5 → 0.4.1

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