@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/dist/array.d.ts +149 -15
- package/dist/array.js +230 -53
- package/dist/array.js.map +1 -1
- package/dist/array.test.js +34 -1
- package/dist/array.test.js.map +1 -1
- package/dist/index.d.ts +1 -120
- package/dist/index.js +1 -197
- package/dist/index.js.map +1 -1
- package/dist/map.d.ts +130 -0
- package/dist/map.js +227 -0
- package/dist/map.js.map +1 -0
- package/dist/map.test.d.ts +1 -0
- package/dist/map.test.js +52 -0
- package/dist/map.test.js.map +1 -0
- package/dist/signal.d.ts +200 -0
- package/dist/signal.js +263 -0
- package/dist/signal.js.map +1 -0
- package/dist/signal.test.js +6 -6
- package/dist/signal.test.js.map +1 -1
- package/package.json +4 -1
- package/src/array.test.ts +36 -1
- package/src/array.ts +250 -67
- package/src/index.ts +1 -265
- package/src/map.test.ts +62 -0
- package/src/map.ts +263 -0
- package/src/signal.test.ts +7 -7
- package/src/signal.ts +361 -0
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("
|
|
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
|
|
4
|
+
import * as Signal from "./signal";
|
|
4
5
|
|
|
5
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
8
|
+
type InstanceProps<A> = {
|
|
9
|
+
array: Array<A>;
|
|
10
|
+
signal: Signal.State<null>;
|
|
11
|
+
equals?: (a: A, b: A) => boolean;
|
|
12
|
+
};
|
|
20
13
|
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
29
|
+
/** @ignore */
|
|
30
|
+
protected constructor(instanceProps: InstanceProps<A>) {
|
|
31
|
+
this[instance] = instanceProps;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
36
|
-
|
|
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
|
|
41
|
-
|
|
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
|
|
46
|
-
|
|
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
|
|
54
|
-
|
|
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
|
|
59
|
-
|
|
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
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
195
|
+
if (index < 0) {
|
|
79
196
|
throw new RangeError(`Signal.Array.set: index ${index} out of bounds`);
|
|
80
197
|
}
|
|
81
|
-
this
|
|
82
|
-
|
|
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
|
|
87
|
-
|
|
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
|
|
93
|
-
|
|
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
|
|
102
|
-
|
|
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
|
|
111
|
-
|
|
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
|
-
|
|
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:
|
|
259
|
+
throw new TypeError(`Signal.Array.splice: start ${start} is not an integer`);
|
|
118
260
|
}
|
|
119
|
-
if (
|
|
120
|
-
throw new
|
|
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
|
|
123
|
-
|
|
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
|
-
|
|
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 (
|
|
296
|
+
if (!Object.hasOwn(this, index)) {
|
|
144
297
|
return None;
|
|
145
298
|
}
|
|
146
|
-
const
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
}
|