@zeix/cause-effect 0.17.2 → 0.18.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/.ai-context.md +163 -226
- package/.cursorrules +41 -35
- package/.github/copilot-instructions.md +166 -116
- package/.zed/settings.json +3 -0
- package/ARCHITECTURE.md +274 -0
- package/CLAUDE.md +197 -202
- package/COLLECTION_REFACTORING.md +161 -0
- package/GUIDE.md +298 -0
- package/README.md +241 -220
- package/REQUIREMENTS.md +100 -0
- package/bench/reactivity.bench.ts +577 -0
- package/index.dev.js +1326 -1174
- package/index.js +1 -1
- package/index.ts +58 -85
- package/package.json +9 -6
- package/src/errors.ts +118 -70
- package/src/graph.ts +601 -0
- package/src/nodes/collection.ts +474 -0
- package/src/nodes/effect.ts +149 -0
- package/src/nodes/list.ts +588 -0
- package/src/nodes/memo.ts +120 -0
- package/src/nodes/sensor.ts +139 -0
- package/src/nodes/state.ts +135 -0
- package/src/nodes/store.ts +383 -0
- package/src/nodes/task.ts +146 -0
- package/src/signal.ts +112 -64
- package/src/util.ts +26 -57
- package/test/batch.test.ts +96 -69
- package/test/benchmark.test.ts +473 -485
- package/test/collection.test.ts +455 -955
- package/test/effect.test.ts +293 -696
- package/test/list.test.ts +332 -857
- package/test/memo.test.ts +380 -0
- package/test/regression.test.ts +156 -0
- package/test/scope.test.ts +191 -0
- package/test/sensor.test.ts +454 -0
- package/test/signal.test.ts +220 -213
- package/test/state.test.ts +217 -271
- package/test/store.test.ts +346 -898
- package/test/task.test.ts +395 -0
- package/test/untrack.test.ts +167 -0
- package/test/util/dependency-graph.ts +2 -2
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +5 -7
- package/types/index.d.ts +13 -15
- package/types/src/errors.d.ts +73 -19
- package/types/src/graph.d.ts +208 -0
- package/types/src/nodes/collection.d.ts +64 -0
- package/types/src/nodes/effect.d.ts +48 -0
- package/types/src/nodes/list.d.ts +65 -0
- package/types/src/nodes/memo.d.ts +57 -0
- package/types/src/nodes/sensor.d.ts +75 -0
- package/types/src/nodes/state.d.ts +78 -0
- package/types/src/nodes/store.d.ts +51 -0
- package/types/src/nodes/task.d.ts +73 -0
- package/types/src/signal.d.ts +43 -28
- package/types/src/util.d.ts +9 -16
- package/archive/benchmark.ts +0 -688
- package/archive/collection.ts +0 -310
- package/archive/computed.ts +0 -198
- package/archive/list.ts +0 -544
- package/archive/memo.ts +0 -140
- package/archive/state.ts +0 -90
- package/archive/store.ts +0 -357
- package/archive/task.ts +0 -191
- package/src/classes/collection.ts +0 -298
- package/src/classes/composite.ts +0 -171
- package/src/classes/computed.ts +0 -392
- package/src/classes/list.ts +0 -310
- package/src/classes/ref.ts +0 -96
- package/src/classes/state.ts +0 -131
- package/src/classes/store.ts +0 -227
- package/src/diff.ts +0 -138
- package/src/effect.ts +0 -96
- package/src/match.ts +0 -45
- package/src/resolve.ts +0 -49
- package/src/system.ts +0 -275
- package/test/computed.test.ts +0 -1126
- package/test/diff.test.ts +0 -955
- package/test/match.test.ts +0 -388
- package/test/ref.test.ts +0 -381
- package/test/resolve.test.ts +0 -154
- package/types/src/classes/collection.d.ts +0 -47
- package/types/src/classes/composite.d.ts +0 -15
- package/types/src/classes/computed.d.ts +0 -114
- package/types/src/classes/list.d.ts +0 -41
- package/types/src/classes/ref.d.ts +0 -48
- package/types/src/classes/state.d.ts +0 -61
- package/types/src/classes/store.d.ts +0 -51
- package/types/src/diff.d.ts +0 -28
- package/types/src/effect.d.ts +0 -15
- package/types/src/match.d.ts +0 -21
- package/types/src/resolve.d.ts +0 -29
- package/types/src/system.d.ts +0 -81
package/test/collection.test.ts
CHANGED
|
@@ -1,1112 +1,612 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
import {
|
|
3
|
+
batch,
|
|
4
|
+
createCollection,
|
|
3
5
|
createEffect,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
+
createList,
|
|
7
|
+
createScope,
|
|
8
|
+
createState,
|
|
9
|
+
type DiffResult,
|
|
6
10
|
isCollection,
|
|
7
|
-
|
|
8
|
-
UNSET,
|
|
11
|
+
isList,
|
|
9
12
|
} from '../index.ts'
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
describe('creation and basic operations', () => {
|
|
13
|
-
test('creates collection with initial values from list', () => {
|
|
14
|
-
const numbers = new List([1, 2, 3])
|
|
15
|
-
const doubled = new DerivedCollection(
|
|
16
|
-
numbers,
|
|
17
|
-
(value: number) => value * 2,
|
|
18
|
-
)
|
|
19
|
-
|
|
20
|
-
expect(doubled.length).toBe(3)
|
|
21
|
-
expect(doubled.at(0)?.get()).toBe(2)
|
|
22
|
-
expect(doubled.at(1)?.get()).toBe(4)
|
|
23
|
-
expect(doubled.at(2)?.get()).toBe(6)
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
test('creates collection from function source', () => {
|
|
27
|
-
const doubled = new DerivedCollection(
|
|
28
|
-
() => new List([10, 20, 30]),
|
|
29
|
-
(value: number) => value * 2,
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
expect(doubled.length).toBe(3)
|
|
33
|
-
expect(doubled.at(0)?.get()).toBe(20)
|
|
34
|
-
expect(doubled.at(1)?.get()).toBe(40)
|
|
35
|
-
expect(doubled.at(2)?.get()).toBe(60)
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
test('has Symbol.toStringTag of Collection', () => {
|
|
39
|
-
const list = new List([1, 2, 3])
|
|
40
|
-
const collection = new DerivedCollection(list, (x: number) => x)
|
|
41
|
-
expect(Object.prototype.toString.call(collection)).toBe(
|
|
42
|
-
'[object Collection]',
|
|
43
|
-
)
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
test('isCollection identifies collection instances correctly', () => {
|
|
47
|
-
const store = createStore({ a: 1 })
|
|
48
|
-
const list = new List([1, 2, 3])
|
|
49
|
-
const collection = new DerivedCollection(list, (x: number) => x)
|
|
50
|
-
|
|
51
|
-
expect(isCollection(collection)).toBe(true)
|
|
52
|
-
expect(isCollection(list)).toBe(false)
|
|
53
|
-
expect(isCollection(store)).toBe(false)
|
|
54
|
-
expect(isCollection({})).toBe(false)
|
|
55
|
-
expect(isCollection(null)).toBe(false)
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
test('get() returns the complete collection value', () => {
|
|
59
|
-
const numbers = new List([1, 2, 3])
|
|
60
|
-
const doubled = new DerivedCollection(
|
|
61
|
-
numbers,
|
|
62
|
-
(value: number) => value * 2,
|
|
63
|
-
)
|
|
14
|
+
/* === Utility Functions === */
|
|
64
15
|
|
|
65
|
-
|
|
66
|
-
expect(result).toEqual([2, 4, 6])
|
|
67
|
-
expect(Array.isArray(result)).toBe(true)
|
|
68
|
-
})
|
|
69
|
-
})
|
|
16
|
+
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
|
70
17
|
|
|
71
|
-
|
|
72
|
-
test('length property works for collections', () => {
|
|
73
|
-
const numbers = new List([1, 2, 3, 4, 5])
|
|
74
|
-
const collection = new DerivedCollection(
|
|
75
|
-
numbers,
|
|
76
|
-
(x: number) => x * 2,
|
|
77
|
-
)
|
|
78
|
-
expect(collection.length).toBe(5)
|
|
79
|
-
})
|
|
18
|
+
/* === Tests === */
|
|
80
19
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
(x: number) => x * 2,
|
|
86
|
-
)
|
|
20
|
+
describe('Collection', () => {
|
|
21
|
+
describe('createCollection', () => {
|
|
22
|
+
test('should create a collection with initial values', () => {
|
|
23
|
+
const col = createCollection(() => () => {}, { value: [1, 2, 3] })
|
|
87
24
|
|
|
88
|
-
expect(
|
|
89
|
-
|
|
90
|
-
expect(
|
|
25
|
+
expect(col.get()).toEqual([1, 2, 3])
|
|
26
|
+
expect(col.length).toBe(3)
|
|
27
|
+
expect(isCollection(col)).toBe(true)
|
|
91
28
|
})
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
describe('index-based access', () => {
|
|
95
|
-
test('properties can be accessed via computed signals', () => {
|
|
96
|
-
const items = new List([10, 20, 30])
|
|
97
|
-
const doubled = new DerivedCollection(items, (x: number) => x * 2)
|
|
98
29
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
expect(doubled.at(2)?.get()).toBe(60)
|
|
102
|
-
})
|
|
30
|
+
test('should create an empty collection', () => {
|
|
31
|
+
const col = createCollection<number>(() => () => {})
|
|
103
32
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const collection = new DerivedCollection(items, (x: number) => x)
|
|
107
|
-
expect(collection[5]).toBeUndefined()
|
|
33
|
+
expect(col.get()).toEqual([])
|
|
34
|
+
expect(col.length).toBe(0)
|
|
108
35
|
})
|
|
109
36
|
|
|
110
|
-
test('
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
numbers,
|
|
114
|
-
(x: number) => x * 2,
|
|
115
|
-
)
|
|
116
|
-
expect(collection.at(1)?.get()).toBe(4)
|
|
37
|
+
test('should have Symbol.toStringTag of "Collection"', () => {
|
|
38
|
+
const col = createCollection(() => () => {}, { value: [1] })
|
|
39
|
+
expect(col[Symbol.toStringTag]).toBe('Collection')
|
|
117
40
|
})
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
describe('key-based access methods', () => {
|
|
121
|
-
test('byKey() returns computed signal for existing keys', () => {
|
|
122
|
-
const numbers = new List([1, 2, 3])
|
|
123
|
-
const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
|
|
124
|
-
|
|
125
|
-
const key0 = numbers.keyAt(0)
|
|
126
|
-
const key1 = numbers.keyAt(1)
|
|
127
41
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
expect(doubled.byKey(key0!)).toBeDefined()
|
|
132
|
-
// biome-ignore lint/style/noNonNullAssertion: test
|
|
133
|
-
expect(doubled.byKey(key1!)).toBeDefined()
|
|
134
|
-
// biome-ignore lint/style/noNonNullAssertion: test
|
|
135
|
-
expect(doubled.byKey(key0!)?.get()).toBe(2)
|
|
136
|
-
// biome-ignore lint/style/noNonNullAssertion: test
|
|
137
|
-
expect(doubled.byKey(key1!)?.get()).toBe(4)
|
|
42
|
+
test('should have Symbol.isConcatSpreadable set to true', () => {
|
|
43
|
+
const col = createCollection(() => () => {}, { value: [1] })
|
|
44
|
+
expect(col[Symbol.isConcatSpreadable]).toBe(true)
|
|
138
45
|
})
|
|
139
46
|
|
|
140
|
-
test('keyAt()
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
47
|
+
test('should support at(), byKey(), keyAt(), indexOfKey()', () => {
|
|
48
|
+
const col = createCollection(() => () => {}, {
|
|
49
|
+
value: [
|
|
50
|
+
{ id: 'a', name: 'Alice' },
|
|
51
|
+
{ id: 'b', name: 'Bob' },
|
|
52
|
+
],
|
|
53
|
+
keyConfig: item => item.id,
|
|
54
|
+
})
|
|
146
55
|
|
|
147
|
-
expect(
|
|
148
|
-
expect(
|
|
56
|
+
expect(col.keyAt(0)).toBe('a')
|
|
57
|
+
expect(col.keyAt(1)).toBe('b')
|
|
58
|
+
expect(col.indexOfKey('b')).toBe(1)
|
|
149
59
|
// biome-ignore lint/style/noNonNullAssertion: test
|
|
150
|
-
expect(
|
|
60
|
+
expect(col.byKey('a')!.get()).toEqual({ id: 'a', name: 'Alice' })
|
|
151
61
|
// biome-ignore lint/style/noNonNullAssertion: test
|
|
152
|
-
expect(
|
|
62
|
+
expect(col.at(1)!.get()).toEqual({ id: 'b', name: 'Bob' })
|
|
153
63
|
})
|
|
154
|
-
})
|
|
155
64
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
|
|
160
|
-
|
|
161
|
-
let lastArray: number[] = []
|
|
162
|
-
createEffect(() => {
|
|
163
|
-
lastArray = doubled.get()
|
|
65
|
+
test('should support iteration', () => {
|
|
66
|
+
const col = createCollection(() => () => {}, {
|
|
67
|
+
value: [10, 20, 30],
|
|
164
68
|
})
|
|
165
69
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
expect(
|
|
70
|
+
const values = []
|
|
71
|
+
for (const signal of col) values.push(signal.get())
|
|
72
|
+
expect(values).toEqual([10, 20, 30])
|
|
169
73
|
})
|
|
170
74
|
|
|
171
|
-
test('
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
(item: { count: number }) => ({ count: item.count * 2 }),
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
let lastItem: { count: number } | undefined
|
|
179
|
-
let itemEffectRuns = 0
|
|
180
|
-
createEffect(() => {
|
|
181
|
-
lastItem = doubled.at(0)?.get()
|
|
182
|
-
itemEffectRuns++
|
|
75
|
+
test('should support custom key config with string prefix', () => {
|
|
76
|
+
const col = createCollection(() => () => {}, {
|
|
77
|
+
value: [10, 20],
|
|
78
|
+
keyConfig: 'item-',
|
|
183
79
|
})
|
|
184
80
|
|
|
185
|
-
expect(
|
|
186
|
-
expect(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
arrayEffectRuns++
|
|
81
|
+
expect(col.keyAt(0)).toBe('item-0')
|
|
82
|
+
expect(col.keyAt(1)).toBe('item-1')
|
|
83
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
84
|
+
expect(col.byKey('item-0')!.get()).toBe(10)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('should support custom createItem factory', () => {
|
|
88
|
+
let guardCalled = false
|
|
89
|
+
const col = createCollection(() => () => {}, {
|
|
90
|
+
value: [5, 10],
|
|
91
|
+
createItem: (_key, value) =>
|
|
92
|
+
createState(value, {
|
|
93
|
+
guard: (v): v is number => {
|
|
94
|
+
guardCalled = true
|
|
95
|
+
return typeof v === 'number'
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
203
98
|
})
|
|
204
99
|
|
|
205
|
-
expect(
|
|
206
|
-
expect(
|
|
207
|
-
|
|
208
|
-
numbers.at(1)?.set(10)
|
|
209
|
-
expect(lastArray).toEqual([2, 20, 6])
|
|
210
|
-
expect(arrayEffectRuns).toBe(2)
|
|
100
|
+
expect(col.get()).toEqual([5, 10])
|
|
101
|
+
expect(guardCalled).toBe(true)
|
|
211
102
|
})
|
|
212
103
|
})
|
|
213
104
|
|
|
214
|
-
describe('
|
|
215
|
-
test('
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
const signals = [...doubled]
|
|
219
|
-
|
|
220
|
-
expect(signals).toHaveLength(3)
|
|
221
|
-
expect(signals[0].get()).toBe(2)
|
|
222
|
-
expect(signals[1].get()).toBe(4)
|
|
223
|
-
expect(signals[2].get()).toBe(6)
|
|
105
|
+
describe('isCollection', () => {
|
|
106
|
+
test('should identify collection signals', () => {
|
|
107
|
+
const col = createCollection(() => () => {}, { value: [1] })
|
|
108
|
+
expect(isCollection(col)).toBe(true)
|
|
224
109
|
})
|
|
225
110
|
|
|
226
|
-
test('
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
expect(
|
|
111
|
+
test('should return false for non-collection values', () => {
|
|
112
|
+
expect(isCollection(42)).toBe(false)
|
|
113
|
+
expect(isCollection(null)).toBe(false)
|
|
114
|
+
expect(isCollection({})).toBe(false)
|
|
115
|
+
expect(
|
|
116
|
+
isList(createCollection(() => () => {}, { value: [1] })),
|
|
117
|
+
).toBe(false)
|
|
230
118
|
})
|
|
231
119
|
})
|
|
232
120
|
|
|
233
|
-
describe('
|
|
234
|
-
test('
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
})
|
|
121
|
+
describe('Watched Lifecycle', () => {
|
|
122
|
+
test('should call start callback on first effect access', () => {
|
|
123
|
+
let started = false
|
|
124
|
+
let cleaned = false
|
|
125
|
+
|
|
126
|
+
const col = createCollection(
|
|
127
|
+
() => {
|
|
128
|
+
started = true
|
|
129
|
+
return () => {
|
|
130
|
+
cleaned = true
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
{ value: [1] },
|
|
134
|
+
)
|
|
248
135
|
|
|
249
|
-
|
|
250
|
-
const items = new List([1, 2, 3])
|
|
251
|
-
const doubled = new DerivedCollection(items, (x: number) => x * 2)
|
|
136
|
+
expect(started).toBe(false)
|
|
252
137
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
138
|
+
const dispose = createScope(() => {
|
|
139
|
+
createEffect(() => {
|
|
140
|
+
void col.length
|
|
141
|
+
})
|
|
256
142
|
})
|
|
257
143
|
|
|
258
|
-
|
|
259
|
-
expect(
|
|
260
|
-
})
|
|
261
|
-
|
|
262
|
-
test('triggers HOOK_SORT when source is sorted', () => {
|
|
263
|
-
const numbers = new List([3, 1, 2])
|
|
264
|
-
const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
|
|
144
|
+
expect(started).toBe(true)
|
|
145
|
+
expect(cleaned).toBe(false)
|
|
265
146
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
order = newOrder
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
numbers.sort((a, b) => a - b)
|
|
272
|
-
expect(order).toHaveLength(3)
|
|
273
|
-
expect(doubled.get()).toEqual([2, 4, 6])
|
|
147
|
+
dispose()
|
|
148
|
+
expect(cleaned).toBe(true)
|
|
274
149
|
})
|
|
275
|
-
})
|
|
276
150
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
151
|
+
test('should activate via keys() access in effect', () => {
|
|
152
|
+
let started = false
|
|
153
|
+
const col = createCollection(
|
|
154
|
+
() => {
|
|
155
|
+
started = true
|
|
156
|
+
return () => {}
|
|
157
|
+
},
|
|
158
|
+
{ value: [1] },
|
|
283
159
|
)
|
|
284
|
-
expect(collection.length).toBe(0)
|
|
285
|
-
expect(collection.get()).toEqual([])
|
|
286
|
-
})
|
|
287
160
|
|
|
288
|
-
|
|
289
|
-
const list = new List([1, 2, 3])
|
|
290
|
-
const processed = new DerivedCollection(list, (x: number) =>
|
|
291
|
-
x > 2 ? x : UNSET,
|
|
292
|
-
)
|
|
161
|
+
expect(started).toBe(false)
|
|
293
162
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
163
|
+
const dispose = createScope(() => {
|
|
164
|
+
createEffect(() => {
|
|
165
|
+
void Array.from(col.keys())
|
|
166
|
+
})
|
|
167
|
+
})
|
|
297
168
|
|
|
298
|
-
|
|
299
|
-
const list = new List(['hello', 'world'])
|
|
300
|
-
const lengths = new DerivedCollection(list, (str: string) => ({
|
|
301
|
-
length: str.length,
|
|
302
|
-
}))
|
|
169
|
+
expect(started).toBe(true)
|
|
303
170
|
|
|
304
|
-
|
|
305
|
-
expect(lengths.at(1)?.get()).toEqual({ length: 5 })
|
|
171
|
+
dispose()
|
|
306
172
|
})
|
|
307
173
|
})
|
|
308
174
|
|
|
309
|
-
describe('
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
(x: number) => x * 2,
|
|
316
|
-
)
|
|
317
|
-
const quadrupled = doubled.deriveCollection(
|
|
318
|
-
(x: number) => x * 2,
|
|
319
|
-
)
|
|
320
|
-
|
|
321
|
-
expect(quadrupled.length).toBe(3)
|
|
322
|
-
expect(quadrupled.at(0)?.get()).toBe(4)
|
|
323
|
-
expect(quadrupled.at(1)?.get()).toBe(8)
|
|
324
|
-
expect(quadrupled.at(2)?.get()).toBe(12)
|
|
175
|
+
describe('applyChanges', () => {
|
|
176
|
+
test('should add items', () => {
|
|
177
|
+
let apply: ((changes: DiffResult) => void) | undefined
|
|
178
|
+
const col = createCollection<number>(applyChanges => {
|
|
179
|
+
apply = applyChanges
|
|
180
|
+
return () => {}
|
|
325
181
|
})
|
|
326
182
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
])
|
|
332
|
-
const basicInfo = new DerivedCollection(
|
|
333
|
-
users,
|
|
334
|
-
(user: { name: string; age: number }) => ({
|
|
335
|
-
displayName: user.name.toUpperCase(),
|
|
336
|
-
isAdult: user.age >= 18,
|
|
337
|
-
}),
|
|
338
|
-
)
|
|
339
|
-
const detailedInfo = basicInfo.deriveCollection(
|
|
340
|
-
(info: { displayName: string; isAdult: boolean }) => ({
|
|
341
|
-
...info,
|
|
342
|
-
category: info.isAdult ? 'adult' : 'minor',
|
|
343
|
-
}),
|
|
344
|
-
)
|
|
345
|
-
|
|
346
|
-
expect(detailedInfo.at(0)?.get()).toEqual({
|
|
347
|
-
displayName: 'ALICE',
|
|
348
|
-
isAdult: true,
|
|
349
|
-
category: 'adult',
|
|
350
|
-
})
|
|
351
|
-
expect(detailedInfo.at(1)?.get()).toEqual({
|
|
352
|
-
displayName: 'BOB',
|
|
353
|
-
isAdult: true,
|
|
354
|
-
category: 'adult',
|
|
183
|
+
const values: number[][] = []
|
|
184
|
+
const dispose = createScope(() => {
|
|
185
|
+
createEffect(() => {
|
|
186
|
+
values.push(col.get())
|
|
355
187
|
})
|
|
356
188
|
})
|
|
357
189
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
190
|
+
expect(values).toEqual([[]])
|
|
191
|
+
|
|
192
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
193
|
+
apply!({
|
|
194
|
+
changed: true,
|
|
195
|
+
add: { a: 1, b: 2 },
|
|
196
|
+
change: {},
|
|
197
|
+
remove: {},
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
expect(values.length).toBe(2)
|
|
201
|
+
expect(values[1]).toEqual([1, 2])
|
|
202
|
+
expect(col.length).toBe(2)
|
|
203
|
+
|
|
204
|
+
dispose()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('should change item values', () => {
|
|
208
|
+
let apply: ((changes: DiffResult) => void) | undefined
|
|
209
|
+
const col = createCollection(
|
|
210
|
+
applyChanges => {
|
|
211
|
+
apply = applyChanges
|
|
212
|
+
return () => {}
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
value: [{ id: 'x', val: 1 }],
|
|
216
|
+
keyConfig: item => item.id,
|
|
217
|
+
},
|
|
218
|
+
)
|
|
384
219
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const doubled = new DerivedCollection(
|
|
388
|
-
numbers,
|
|
389
|
-
(x: number) => x * 2,
|
|
390
|
-
)
|
|
391
|
-
const quadrupled = doubled.deriveCollection(
|
|
392
|
-
(x: number) => x * 2,
|
|
393
|
-
)
|
|
394
|
-
|
|
395
|
-
let collectionValue: number[] = []
|
|
396
|
-
let effectRuns = 0
|
|
220
|
+
const values: { id: string; val: number }[][] = []
|
|
221
|
+
const dispose = createScope(() => {
|
|
397
222
|
createEffect(() => {
|
|
398
|
-
|
|
399
|
-
effectRuns++
|
|
223
|
+
values.push(col.get())
|
|
400
224
|
})
|
|
401
|
-
|
|
402
|
-
expect(collectionValue).toEqual([4, 8, 12])
|
|
403
|
-
expect(effectRuns).toBe(1)
|
|
404
|
-
|
|
405
|
-
numbers.add(4)
|
|
406
|
-
expect(collectionValue).toEqual([4, 8, 12, 16])
|
|
407
|
-
expect(effectRuns).toBe(2)
|
|
408
|
-
|
|
409
|
-
numbers.at(1)?.set(5)
|
|
410
|
-
expect(collectionValue).toEqual([4, 20, 12, 16])
|
|
411
|
-
expect(effectRuns).toBe(3)
|
|
412
225
|
})
|
|
413
226
|
|
|
414
|
-
|
|
415
|
-
const numbers = new List([1, 2, 3, 4])
|
|
416
|
-
const doubled = new DerivedCollection(
|
|
417
|
-
numbers,
|
|
418
|
-
(x: number) => x * 2,
|
|
419
|
-
)
|
|
420
|
-
const quadrupled = doubled.deriveCollection(
|
|
421
|
-
(x: number) => x * 2,
|
|
422
|
-
)
|
|
227
|
+
expect(values[0]).toEqual([{ id: 'x', val: 1 }])
|
|
423
228
|
|
|
424
|
-
|
|
229
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
230
|
+
apply!({
|
|
231
|
+
changed: true,
|
|
232
|
+
add: {},
|
|
233
|
+
change: { x: { id: 'x', val: 42 } },
|
|
234
|
+
remove: {},
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
expect(values.length).toBe(2)
|
|
238
|
+
expect(values[1]).toEqual([{ id: 'x', val: 42 }])
|
|
239
|
+
|
|
240
|
+
dispose()
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('should remove items', () => {
|
|
244
|
+
let apply: ((changes: DiffResult) => void) | undefined
|
|
245
|
+
const col = createCollection(
|
|
246
|
+
applyChanges => {
|
|
247
|
+
apply = applyChanges
|
|
248
|
+
return () => {}
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
value: [
|
|
252
|
+
{ id: 'a', v: 1 },
|
|
253
|
+
{ id: 'b', v: 2 },
|
|
254
|
+
{ id: 'c', v: 3 },
|
|
255
|
+
],
|
|
256
|
+
keyConfig: item => item.id,
|
|
257
|
+
},
|
|
258
|
+
)
|
|
425
259
|
|
|
426
|
-
|
|
427
|
-
|
|
260
|
+
const values: { id: string; v: number }[][] = []
|
|
261
|
+
const dispose = createScope(() => {
|
|
262
|
+
createEffect(() => {
|
|
263
|
+
values.push(col.get())
|
|
264
|
+
})
|
|
428
265
|
})
|
|
429
|
-
})
|
|
430
266
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
267
|
+
expect(values[0]).toEqual([
|
|
268
|
+
{ id: 'a', v: 1 },
|
|
269
|
+
{ id: 'b', v: 2 },
|
|
270
|
+
{ id: 'c', v: 3 },
|
|
271
|
+
])
|
|
272
|
+
|
|
273
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
274
|
+
apply!({
|
|
275
|
+
changed: true,
|
|
276
|
+
add: {},
|
|
277
|
+
change: {},
|
|
278
|
+
remove: { b: null },
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
expect(values.length).toBe(2)
|
|
282
|
+
expect(values[1]).toEqual([
|
|
283
|
+
{ id: 'a', v: 1 },
|
|
284
|
+
{ id: 'c', v: 3 },
|
|
285
|
+
])
|
|
286
|
+
expect(col.length).toBe(2)
|
|
287
|
+
|
|
288
|
+
dispose()
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test('should handle mixed add/change/remove', () => {
|
|
292
|
+
let apply: ((changes: DiffResult) => void) | undefined
|
|
293
|
+
const col = createCollection(
|
|
294
|
+
applyChanges => {
|
|
295
|
+
apply = applyChanges
|
|
296
|
+
return () => {}
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
value: [
|
|
300
|
+
{ id: 'a', v: 1 },
|
|
301
|
+
{ id: 'b', v: 2 },
|
|
302
|
+
],
|
|
303
|
+
keyConfig: item => item.id,
|
|
304
|
+
},
|
|
305
|
+
)
|
|
458
306
|
|
|
307
|
+
const values: { id: string; v: number }[][] = []
|
|
308
|
+
const dispose = createScope(() => {
|
|
459
309
|
createEffect(() => {
|
|
460
|
-
|
|
461
|
-
results.push(...values)
|
|
462
|
-
effectRuns++
|
|
310
|
+
values.push(col.get())
|
|
463
311
|
})
|
|
464
|
-
|
|
465
|
-
// Wait for async computations to complete
|
|
466
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
467
|
-
|
|
468
|
-
// Should have received the computed values
|
|
469
|
-
expect(results.slice(-3)).toEqual([4, 8, 12])
|
|
470
|
-
expect(effectRuns).toBeGreaterThanOrEqual(1)
|
|
471
312
|
})
|
|
472
313
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
.replace(/\s+/g, '-'),
|
|
500
|
-
timestamp: Date.now(),
|
|
501
|
-
}
|
|
502
|
-
},
|
|
503
|
-
)
|
|
504
|
-
|
|
505
|
-
// Use effect to test async behavior
|
|
506
|
-
let enrichedResults: Array<{
|
|
507
|
-
userId: number
|
|
508
|
-
displayName: string
|
|
509
|
-
slug: string
|
|
510
|
-
timestamp: number
|
|
511
|
-
}> = []
|
|
314
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
315
|
+
apply!({
|
|
316
|
+
changed: true,
|
|
317
|
+
add: { c: { id: 'c', v: 3 } },
|
|
318
|
+
change: { a: { id: 'a', v: 10 } },
|
|
319
|
+
remove: { b: null },
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
expect(values.length).toBe(2)
|
|
323
|
+
expect(values[1]).toEqual([
|
|
324
|
+
{ id: 'a', v: 10 },
|
|
325
|
+
{ id: 'c', v: 3 },
|
|
326
|
+
])
|
|
327
|
+
|
|
328
|
+
dispose()
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
test('should skip when changed is false', () => {
|
|
332
|
+
let apply: ((changes: DiffResult) => void) | undefined
|
|
333
|
+
const col = createCollection(
|
|
334
|
+
applyChanges => {
|
|
335
|
+
apply = applyChanges
|
|
336
|
+
return () => {}
|
|
337
|
+
},
|
|
338
|
+
{ value: [1] },
|
|
339
|
+
)
|
|
512
340
|
|
|
341
|
+
let callCount = 0
|
|
342
|
+
const dispose = createScope(() => {
|
|
513
343
|
createEffect(() => {
|
|
514
|
-
|
|
344
|
+
void col.get()
|
|
345
|
+
callCount++
|
|
515
346
|
})
|
|
347
|
+
})
|
|
516
348
|
|
|
517
|
-
|
|
518
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
349
|
+
expect(callCount).toBe(1)
|
|
519
350
|
|
|
520
|
-
|
|
351
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
352
|
+
apply!({ changed: false, add: {}, change: {}, remove: {} })
|
|
521
353
|
|
|
522
|
-
|
|
354
|
+
expect(callCount).toBe(1)
|
|
523
355
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
expect(result1?.slug).toBe('alice')
|
|
527
|
-
expect(typeof result1?.timestamp).toBe('number')
|
|
356
|
+
dispose()
|
|
357
|
+
})
|
|
528
358
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
359
|
+
test('should trigger effects on structural changes', () => {
|
|
360
|
+
let apply: ((changes: DiffResult) => void) | undefined
|
|
361
|
+
const col = createCollection<string>(applyChanges => {
|
|
362
|
+
apply = applyChanges
|
|
363
|
+
return () => {}
|
|
533
364
|
})
|
|
534
365
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
const doubled = new DerivedCollection(
|
|
538
|
-
numbers,
|
|
539
|
-
(x: number) => x * 2,
|
|
540
|
-
)
|
|
541
|
-
const asyncQuadrupled = doubled.deriveCollection(
|
|
542
|
-
async (x: number, abort: AbortSignal) => {
|
|
543
|
-
await new Promise(resolve => setTimeout(resolve, 10))
|
|
544
|
-
if (abort.aborted) throw new Error('Aborted')
|
|
545
|
-
return x * 2
|
|
546
|
-
},
|
|
547
|
-
)
|
|
548
|
-
|
|
549
|
-
const effectValues: number[][] = []
|
|
366
|
+
let effectCount = 0
|
|
367
|
+
const dispose = createScope(() => {
|
|
550
368
|
createEffect(() => {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
effectValues.push(currentValue)
|
|
369
|
+
void col.length
|
|
370
|
+
effectCount++
|
|
554
371
|
})
|
|
372
|
+
})
|
|
555
373
|
|
|
556
|
-
|
|
557
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
558
|
-
|
|
559
|
-
// Initial empty array (async values not resolved yet)
|
|
560
|
-
expect(effectValues[0]).toEqual([])
|
|
561
|
-
|
|
562
|
-
// Trigger individual computations
|
|
563
|
-
asyncQuadrupled.at(0)?.get()
|
|
564
|
-
asyncQuadrupled.at(1)?.get()
|
|
565
|
-
asyncQuadrupled.at(2)?.get()
|
|
566
|
-
|
|
567
|
-
// Wait for effects to process
|
|
568
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
374
|
+
expect(effectCount).toBe(1)
|
|
569
375
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
376
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
377
|
+
apply!({
|
|
378
|
+
changed: true,
|
|
379
|
+
add: { a: 'hello' },
|
|
380
|
+
change: {},
|
|
381
|
+
remove: {},
|
|
573
382
|
})
|
|
574
383
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
(x: number) => x * 2,
|
|
580
|
-
)
|
|
581
|
-
let abortCalled = false
|
|
582
|
-
|
|
583
|
-
const slowCollection = doubled.deriveCollection(
|
|
584
|
-
async (x: number, abort: AbortSignal) => {
|
|
585
|
-
abort.addEventListener('abort', () => {
|
|
586
|
-
abortCalled = true
|
|
587
|
-
})
|
|
588
|
-
|
|
589
|
-
// Long delay to allow cancellation
|
|
590
|
-
const timeout = new Promise(resolve =>
|
|
591
|
-
setTimeout(resolve, 100),
|
|
592
|
-
)
|
|
593
|
-
await timeout
|
|
594
|
-
|
|
595
|
-
if (abort.aborted) throw new Error('Aborted')
|
|
596
|
-
return x * 2
|
|
597
|
-
},
|
|
598
|
-
)
|
|
599
|
-
|
|
600
|
-
// Start computation
|
|
601
|
-
const _awaited = slowCollection.at(0)?.get()
|
|
602
|
-
|
|
603
|
-
// Change source to trigger cancellation
|
|
604
|
-
numbers.at(0)?.set(10)
|
|
605
|
-
|
|
606
|
-
// Wait for potential abort
|
|
607
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
608
|
-
|
|
609
|
-
expect(abortCalled).toBe(true)
|
|
610
|
-
})
|
|
384
|
+
expect(effectCount).toBe(2)
|
|
385
|
+
expect(col.length).toBe(1)
|
|
386
|
+
|
|
387
|
+
dispose()
|
|
611
388
|
})
|
|
612
389
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
(x: number) => x * 2,
|
|
619
|
-
)
|
|
620
|
-
const quadrupled = doubled.deriveCollection(
|
|
621
|
-
(x: number) => x * 2,
|
|
622
|
-
)
|
|
623
|
-
const octupled = quadrupled.deriveCollection(
|
|
624
|
-
(x: number) => x * 2,
|
|
625
|
-
)
|
|
626
|
-
|
|
627
|
-
expect(octupled.at(0)?.get()).toBe(8)
|
|
628
|
-
expect(octupled.at(1)?.get()).toBe(16)
|
|
629
|
-
expect(octupled.at(2)?.get()).toBe(24)
|
|
390
|
+
test('should batch multiple calls', () => {
|
|
391
|
+
let apply: ((changes: DiffResult) => void) | undefined
|
|
392
|
+
const col = createCollection<number>(applyChanges => {
|
|
393
|
+
apply = applyChanges
|
|
394
|
+
return () => {}
|
|
630
395
|
})
|
|
631
396
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
const doubled = new DerivedCollection(
|
|
635
|
-
numbers,
|
|
636
|
-
(x: number) => x * 2,
|
|
637
|
-
)
|
|
638
|
-
const quadrupled = doubled.deriveCollection(
|
|
639
|
-
(x: number) => x * 2,
|
|
640
|
-
)
|
|
641
|
-
|
|
642
|
-
const asyncOctupled = quadrupled.deriveCollection(
|
|
643
|
-
async (x: number, abort: AbortSignal) => {
|
|
644
|
-
await new Promise(resolve => setTimeout(resolve, 10))
|
|
645
|
-
if (abort.aborted) throw new Error('Aborted')
|
|
646
|
-
return x * 2
|
|
647
|
-
},
|
|
648
|
-
)
|
|
649
|
-
|
|
650
|
-
// Use effect to test chained async behavior
|
|
651
|
-
let chainedResults: number[] = []
|
|
652
|
-
|
|
397
|
+
let effectCount = 0
|
|
398
|
+
const dispose = createScope(() => {
|
|
653
399
|
createEffect(() => {
|
|
654
|
-
|
|
400
|
+
void col.get()
|
|
401
|
+
effectCount++
|
|
655
402
|
})
|
|
656
|
-
|
|
657
|
-
// Wait for async computations to complete
|
|
658
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
659
|
-
|
|
660
|
-
expect(chainedResults).toEqual([8, 16, 24])
|
|
661
403
|
})
|
|
662
|
-
})
|
|
663
404
|
|
|
664
|
-
|
|
665
|
-
test('provides index-based access to computed signals', () => {
|
|
666
|
-
const numbers = new List([1, 2, 3])
|
|
667
|
-
const doubled = new DerivedCollection(
|
|
668
|
-
numbers,
|
|
669
|
-
(x: number) => x * 2,
|
|
670
|
-
)
|
|
671
|
-
const quadrupled = doubled.deriveCollection(
|
|
672
|
-
(x: number) => x * 2,
|
|
673
|
-
)
|
|
674
|
-
|
|
675
|
-
expect(quadrupled.at(0)?.get()).toBe(4)
|
|
676
|
-
expect(quadrupled.at(1)?.get()).toBe(8)
|
|
677
|
-
expect(quadrupled.at(2)?.get()).toBe(12)
|
|
678
|
-
expect(quadrupled.at(10)).toBeUndefined()
|
|
679
|
-
})
|
|
405
|
+
expect(effectCount).toBe(1)
|
|
680
406
|
|
|
681
|
-
|
|
682
|
-
const numbers = new List([1, 2, 3])
|
|
683
|
-
const doubled = new DerivedCollection(
|
|
684
|
-
numbers,
|
|
685
|
-
(x: number) => x * 2,
|
|
686
|
-
)
|
|
687
|
-
const quadrupled = doubled.deriveCollection(
|
|
688
|
-
(x: number) => x * 2,
|
|
689
|
-
)
|
|
690
|
-
|
|
691
|
-
const key0 = quadrupled.keyAt(0)
|
|
692
|
-
const key1 = quadrupled.keyAt(1)
|
|
693
|
-
|
|
694
|
-
expect(key0).toBeDefined()
|
|
695
|
-
expect(key1).toBeDefined()
|
|
407
|
+
batch(() => {
|
|
696
408
|
// biome-ignore lint/style/noNonNullAssertion: test
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
// biome-ignore lint/style/noNonNullAssertion: test
|
|
703
|
-
expect(quadrupled.byKey(key1!)?.get()).toBe(8)
|
|
704
|
-
})
|
|
705
|
-
|
|
706
|
-
test('supports iteration', () => {
|
|
707
|
-
const numbers = new List([1, 2, 3])
|
|
708
|
-
const doubled = new DerivedCollection(
|
|
709
|
-
numbers,
|
|
710
|
-
(x: number) => x * 2,
|
|
711
|
-
)
|
|
712
|
-
const quadrupled = doubled.deriveCollection(
|
|
713
|
-
(x: number) => x * 2,
|
|
714
|
-
)
|
|
715
|
-
|
|
716
|
-
const signals = [...quadrupled]
|
|
717
|
-
expect(signals).toHaveLength(3)
|
|
718
|
-
expect(signals[0].get()).toBe(4)
|
|
719
|
-
expect(signals[1].get()).toBe(8)
|
|
720
|
-
expect(signals[2].get()).toBe(12)
|
|
721
|
-
})
|
|
722
|
-
})
|
|
723
|
-
|
|
724
|
-
describe('derived collection event handling', () => {
|
|
725
|
-
test('emits add events when source adds items', () => {
|
|
726
|
-
const numbers = new List([1, 2])
|
|
727
|
-
const doubled = new DerivedCollection(
|
|
728
|
-
numbers,
|
|
729
|
-
(x: number) => x * 2,
|
|
730
|
-
)
|
|
731
|
-
const quadrupled = doubled.deriveCollection(
|
|
732
|
-
(x: number) => x * 2,
|
|
733
|
-
)
|
|
734
|
-
|
|
735
|
-
let addedKeys: readonly string[] | undefined
|
|
736
|
-
quadrupled.on('add', keys => {
|
|
737
|
-
addedKeys = keys
|
|
409
|
+
apply!({
|
|
410
|
+
changed: true,
|
|
411
|
+
add: { a: 1 },
|
|
412
|
+
change: {},
|
|
413
|
+
remove: {},
|
|
738
414
|
})
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
})
|
|
746
|
-
|
|
747
|
-
test('emits remove events when source removes items', () => {
|
|
748
|
-
const numbers = new List([1, 2, 3])
|
|
749
|
-
const doubled = new DerivedCollection(
|
|
750
|
-
numbers,
|
|
751
|
-
(x: number) => x * 2,
|
|
752
|
-
)
|
|
753
|
-
const quadrupled = doubled.deriveCollection(
|
|
754
|
-
(x: number) => x * 2,
|
|
755
|
-
)
|
|
756
|
-
|
|
757
|
-
let removedKeys: readonly string[] | undefined
|
|
758
|
-
quadrupled.on('remove', keys => {
|
|
759
|
-
removedKeys = keys
|
|
415
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
416
|
+
apply!({
|
|
417
|
+
changed: true,
|
|
418
|
+
add: { b: 2 },
|
|
419
|
+
change: {},
|
|
420
|
+
remove: {},
|
|
760
421
|
})
|
|
761
|
-
|
|
762
|
-
numbers.remove(1)
|
|
763
|
-
expect(removedKeys).toHaveLength(1)
|
|
764
422
|
})
|
|
765
423
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
const doubled = new DerivedCollection(
|
|
769
|
-
numbers,
|
|
770
|
-
(x: number) => x * 2,
|
|
771
|
-
)
|
|
772
|
-
const quadrupled = doubled.deriveCollection(
|
|
773
|
-
(x: number) => x * 2,
|
|
774
|
-
)
|
|
775
|
-
|
|
776
|
-
let sortedKeys: readonly string[] | undefined
|
|
777
|
-
quadrupled.on('sort', newOrder => {
|
|
778
|
-
sortedKeys = newOrder
|
|
779
|
-
})
|
|
424
|
+
expect(effectCount).toBe(2)
|
|
425
|
+
expect(col.get()).toEqual([1, 2])
|
|
780
426
|
|
|
781
|
-
|
|
782
|
-
expect(sortedKeys).toHaveLength(3)
|
|
783
|
-
expect(quadrupled.get()).toEqual([4, 8, 12])
|
|
784
|
-
})
|
|
427
|
+
dispose()
|
|
785
428
|
})
|
|
429
|
+
})
|
|
786
430
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
empty,
|
|
792
|
-
(x: number) => x * 2,
|
|
793
|
-
)
|
|
794
|
-
const derived = emptyCollection.deriveCollection(
|
|
795
|
-
(x: number) => x * 2,
|
|
796
|
-
)
|
|
797
|
-
|
|
798
|
-
expect(derived.length).toBe(0)
|
|
799
|
-
expect(derived.get()).toEqual([])
|
|
800
|
-
})
|
|
801
|
-
|
|
802
|
-
test('handles UNSET values in transformation', () => {
|
|
803
|
-
const list = new List([1, 2, 3])
|
|
804
|
-
const filtered = new DerivedCollection(list, (x: number) =>
|
|
805
|
-
x > 1 ? { value: x } : UNSET,
|
|
806
|
-
)
|
|
807
|
-
const doubled = filtered.deriveCollection(
|
|
808
|
-
(x: { value: number }) => ({ value: x.value * 2 }),
|
|
809
|
-
)
|
|
810
|
-
|
|
811
|
-
expect(doubled.get()).toEqual([{ value: 4 }, { value: 6 }])
|
|
812
|
-
})
|
|
431
|
+
describe('deriveCollection', () => {
|
|
432
|
+
test('should transform list values with sync callback', () => {
|
|
433
|
+
const numbers = createList([1, 2, 3])
|
|
434
|
+
const doubled = numbers.deriveCollection((v: number) => v * 2)
|
|
813
435
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
{ id: 1, data: { value: 10, active: true } },
|
|
817
|
-
{ id: 2, data: { value: 20, active: false } },
|
|
818
|
-
])
|
|
819
|
-
|
|
820
|
-
const processed = new DerivedCollection(
|
|
821
|
-
items,
|
|
822
|
-
(item: {
|
|
823
|
-
id: number
|
|
824
|
-
data: { value: number; active: boolean }
|
|
825
|
-
}) => ({
|
|
826
|
-
itemId: item.id,
|
|
827
|
-
processedValue: item.data.value * 2,
|
|
828
|
-
status: item.data.active ? 'active' : 'inactive',
|
|
829
|
-
}),
|
|
830
|
-
)
|
|
831
|
-
|
|
832
|
-
const enhanced = processed.deriveCollection(
|
|
833
|
-
(item: {
|
|
834
|
-
itemId: number
|
|
835
|
-
processedValue: number
|
|
836
|
-
status: string
|
|
837
|
-
}) => ({
|
|
838
|
-
...item,
|
|
839
|
-
category: item.processedValue > 15 ? 'high' : 'low',
|
|
840
|
-
}),
|
|
841
|
-
)
|
|
842
|
-
|
|
843
|
-
expect(enhanced.at(0)?.get().itemId).toBe(1)
|
|
844
|
-
expect(enhanced.at(0)?.get().processedValue).toBe(20)
|
|
845
|
-
expect(enhanced.at(0)?.get().status).toBe('active')
|
|
846
|
-
expect(enhanced.at(0)?.get().category).toBe('high')
|
|
847
|
-
expect(enhanced.at(1)?.get().itemId).toBe(2)
|
|
848
|
-
expect(enhanced.at(1)?.get().processedValue).toBe(40)
|
|
849
|
-
expect(enhanced.at(1)?.get().status).toBe('inactive')
|
|
850
|
-
expect(enhanced.at(1)?.get().category).toBe('high')
|
|
851
|
-
})
|
|
436
|
+
expect(doubled.get()).toEqual([2, 4, 6])
|
|
437
|
+
expect(doubled.length).toBe(3)
|
|
852
438
|
})
|
|
853
|
-
})
|
|
854
|
-
|
|
855
|
-
describe('hooks system', () => {
|
|
856
|
-
test('Collection HOOK_WATCH is called when effect accesses collection.get()', () => {
|
|
857
|
-
const numbers = new List([10, 20, 30])
|
|
858
|
-
const doubled = numbers.deriveCollection(x => x * 2)
|
|
859
439
|
|
|
860
|
-
|
|
861
|
-
|
|
440
|
+
test('should transform values with async callback', async () => {
|
|
441
|
+
const numbers = createList([1, 2, 3])
|
|
442
|
+
const doubled = numbers.deriveCollection(
|
|
443
|
+
async (v: number, abort: AbortSignal) => {
|
|
444
|
+
await wait(10)
|
|
445
|
+
if (abort.aborted) throw new Error('Aborted')
|
|
446
|
+
return v * 2
|
|
447
|
+
},
|
|
448
|
+
)
|
|
862
449
|
|
|
863
|
-
//
|
|
864
|
-
doubled.
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
450
|
+
// Trigger computation
|
|
451
|
+
for (let i = 0; i < doubled.length; i++) {
|
|
452
|
+
try {
|
|
453
|
+
doubled.at(i)?.get()
|
|
454
|
+
} catch {
|
|
455
|
+
// UnsetSignalValueError before resolution
|
|
868
456
|
}
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
expect(collectionHookWatchCalled).toBe(false)
|
|
457
|
+
}
|
|
872
458
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
effectValue = doubled.get()
|
|
877
|
-
})
|
|
459
|
+
await wait(50)
|
|
460
|
+
expect(doubled.get()).toEqual([2, 4, 6])
|
|
461
|
+
})
|
|
878
462
|
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
463
|
+
test('should handle empty source list', () => {
|
|
464
|
+
const empty = createList<number>([])
|
|
465
|
+
const doubled = empty.deriveCollection((v: number) => v * 2)
|
|
882
466
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
expect(collectionUnwatchCalled).toBe(true)
|
|
467
|
+
expect(doubled.get()).toEqual([])
|
|
468
|
+
expect(doubled.length).toBe(0)
|
|
886
469
|
})
|
|
887
470
|
|
|
888
|
-
test('
|
|
889
|
-
const
|
|
890
|
-
const doubled =
|
|
891
|
-
|
|
892
|
-
// Set up hook on source item BEFORE creating the effect
|
|
893
|
-
// biome-ignore lint/style/noNonNullAssertion: test
|
|
894
|
-
const firstSourceItem = numbers.at(0)!
|
|
895
|
-
let sourceItemHookCalled = false
|
|
896
|
-
|
|
897
|
-
firstSourceItem.on('watch', () => {
|
|
898
|
-
sourceItemHookCalled = true
|
|
899
|
-
return () => {
|
|
900
|
-
// Note: Unwatch behavior in computed signals is complex and depends on
|
|
901
|
-
// internal watcher management. We focus on verifying hook triggering.
|
|
902
|
-
}
|
|
903
|
-
})
|
|
471
|
+
test('should return Signal at index', () => {
|
|
472
|
+
const list = createList([1, 2, 3])
|
|
473
|
+
const doubled = list.deriveCollection((v: number) => v * 2)
|
|
904
474
|
|
|
905
|
-
expect(
|
|
475
|
+
expect(doubled.at(0)?.get()).toBe(2)
|
|
476
|
+
expect(doubled.at(1)?.get()).toBe(4)
|
|
477
|
+
expect(doubled.at(2)?.get()).toBe(6)
|
|
478
|
+
expect(doubled.at(5)).toBeUndefined()
|
|
479
|
+
})
|
|
906
480
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
const
|
|
910
|
-
const firstCollectionItem = doubled.at(0)
|
|
911
|
-
effectValue = firstCollectionItem?.get()
|
|
912
|
-
})
|
|
481
|
+
test('should return Signal by source key', () => {
|
|
482
|
+
const list = createList([10, 20])
|
|
483
|
+
const doubled = list.deriveCollection((v: number) => v * 2)
|
|
913
484
|
|
|
914
|
-
|
|
915
|
-
|
|
485
|
+
// biome-ignore lint/style/noNonNullAssertion: index is within bounds
|
|
486
|
+
const key0 = list.keyAt(0)!
|
|
487
|
+
// biome-ignore lint/style/noNonNullAssertion: index is within bounds
|
|
488
|
+
const key1 = list.keyAt(1)!
|
|
916
489
|
|
|
917
|
-
|
|
490
|
+
expect(doubled.byKey(key0)?.get()).toBe(20)
|
|
491
|
+
expect(doubled.byKey(key1)?.get()).toBe(40)
|
|
918
492
|
})
|
|
919
493
|
|
|
920
|
-
test('
|
|
921
|
-
const
|
|
922
|
-
const
|
|
494
|
+
test('should support keyAt, indexOfKey, and keys', () => {
|
|
495
|
+
const list = createList([10, 20, 30])
|
|
496
|
+
const col = list.deriveCollection((v: number) => v)
|
|
923
497
|
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
return () => {
|
|
932
|
-
collectionUnwatchCalled = true
|
|
933
|
-
}
|
|
934
|
-
})
|
|
498
|
+
const key0 = col.keyAt(0)
|
|
499
|
+
expect(key0).toBeDefined()
|
|
500
|
+
expect(typeof key0).toBe('string')
|
|
501
|
+
// biome-ignore lint/style/noNonNullAssertion: index is within bounds
|
|
502
|
+
expect(col.indexOfKey(key0!)).toBe(0)
|
|
503
|
+
expect([...col.keys()]).toHaveLength(3)
|
|
504
|
+
})
|
|
935
505
|
|
|
936
|
-
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
sourceItemHookCalled = true
|
|
940
|
-
return () => {
|
|
941
|
-
// Source item unwatch behavior is complex in computed context
|
|
942
|
-
}
|
|
943
|
-
})
|
|
506
|
+
test('should support for...of via Symbol.iterator', () => {
|
|
507
|
+
const list = createList([1, 2, 3])
|
|
508
|
+
const doubled = list.deriveCollection((v: number) => v * 2)
|
|
944
509
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
510
|
+
const signals = [...doubled]
|
|
511
|
+
expect(signals).toHaveLength(3)
|
|
512
|
+
expect(signals[0].get()).toBe(2)
|
|
513
|
+
expect(signals[1].get()).toBe(4)
|
|
514
|
+
expect(signals[2].get()).toBe(6)
|
|
515
|
+
})
|
|
950
516
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
517
|
+
test('should react to source additions', () => {
|
|
518
|
+
const list = createList([1, 2])
|
|
519
|
+
const doubled = list.deriveCollection((v: number) => v * 2)
|
|
954
520
|
|
|
955
|
-
|
|
956
|
-
let
|
|
957
|
-
|
|
958
|
-
|
|
521
|
+
let result: number[] = []
|
|
522
|
+
let effectCount = 0
|
|
523
|
+
createEffect(() => {
|
|
524
|
+
result = doubled.get()
|
|
525
|
+
effectCount++
|
|
959
526
|
})
|
|
960
527
|
|
|
961
|
-
expect(
|
|
528
|
+
expect(result).toEqual([2, 4])
|
|
529
|
+
expect(effectCount).toBe(1)
|
|
962
530
|
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
expect(
|
|
966
|
-
|
|
967
|
-
itemCleanup()
|
|
531
|
+
list.add(3)
|
|
532
|
+
expect(result).toEqual([2, 4, 6])
|
|
533
|
+
expect(effectCount).toBe(2)
|
|
968
534
|
})
|
|
969
535
|
|
|
970
|
-
test('
|
|
971
|
-
const
|
|
972
|
-
const
|
|
973
|
-
|
|
974
|
-
let firstItemHookCalled = false
|
|
975
|
-
let secondItemHookCalled = false
|
|
976
|
-
|
|
977
|
-
// Set up hooks on multiple source items
|
|
978
|
-
// biome-ignore lint/style/noNonNullAssertion: test
|
|
979
|
-
const firstSourceItem = items.at(0)!
|
|
980
|
-
// biome-ignore lint/style/noNonNullAssertion: test
|
|
981
|
-
const secondSourceItem = items.at(1)!
|
|
982
|
-
|
|
983
|
-
firstSourceItem.on('watch', () => {
|
|
984
|
-
firstItemHookCalled = true
|
|
985
|
-
return () => {
|
|
986
|
-
// Collection computed signals manage source watching internally
|
|
987
|
-
}
|
|
988
|
-
})
|
|
989
|
-
|
|
990
|
-
secondSourceItem.on('watch', () => {
|
|
991
|
-
secondItemHookCalled = true
|
|
992
|
-
return () => {
|
|
993
|
-
// Collection computed signals manage source watching internally
|
|
994
|
-
}
|
|
995
|
-
})
|
|
536
|
+
test('should react to source removals', () => {
|
|
537
|
+
const list = createList([1, 2, 3])
|
|
538
|
+
const doubled = list.deriveCollection((v: number) => v * 2)
|
|
996
539
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
540
|
+
expect(doubled.get()).toEqual([2, 4, 6])
|
|
541
|
+
list.remove(1)
|
|
542
|
+
expect(doubled.get()).toEqual([2, 6])
|
|
543
|
+
expect(doubled.length).toBe(2)
|
|
544
|
+
})
|
|
1000
545
|
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
546
|
+
test('should react to item mutations', () => {
|
|
547
|
+
const list = createList([1, 2])
|
|
548
|
+
const doubled = list.deriveCollection((v: number) => v * 2)
|
|
1004
549
|
|
|
1005
|
-
|
|
1006
|
-
|
|
550
|
+
let result: number[] = []
|
|
551
|
+
createEffect(() => {
|
|
552
|
+
result = doubled.get()
|
|
1007
553
|
})
|
|
1008
554
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
expect(
|
|
1012
|
-
expect(firstValue).toBe('FIRST')
|
|
1013
|
-
expect(secondValue).toBe('SECOND')
|
|
1014
|
-
|
|
1015
|
-
cleanup1()
|
|
1016
|
-
cleanup2()
|
|
555
|
+
expect(result).toEqual([2, 4])
|
|
556
|
+
list.at(0)?.set(5)
|
|
557
|
+
expect(result).toEqual([10, 4])
|
|
1017
558
|
})
|
|
1018
559
|
|
|
1019
|
-
test('
|
|
1020
|
-
const
|
|
1021
|
-
const
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
const sourceItem = numbers.at(0)!
|
|
1029
|
-
let sourceHookCalled = false
|
|
1030
|
-
|
|
1031
|
-
sourceItem.on('watch', () => {
|
|
1032
|
-
sourceHookCalled = true
|
|
1033
|
-
return () => {
|
|
1034
|
-
// Hook cleanup managed by computed signal system
|
|
1035
|
-
}
|
|
1036
|
-
})
|
|
1037
|
-
|
|
1038
|
-
expect(sourceHookCalled).toBe(false)
|
|
560
|
+
test('async collection should react to changes', async () => {
|
|
561
|
+
const list = createList([1, 2])
|
|
562
|
+
const doubled = list.deriveCollection(
|
|
563
|
+
async (v: number, abort: AbortSignal) => {
|
|
564
|
+
await wait(5)
|
|
565
|
+
if (abort.aborted) throw new Error('Aborted')
|
|
566
|
+
return v * 2
|
|
567
|
+
},
|
|
568
|
+
)
|
|
1039
569
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
effectValue = squared.at(0)?.get()
|
|
570
|
+
const values: number[][] = []
|
|
571
|
+
createEffect(() => {
|
|
572
|
+
values.push([...doubled.get()])
|
|
1044
573
|
})
|
|
1045
574
|
|
|
1046
|
-
|
|
1047
|
-
expect(
|
|
575
|
+
await wait(20)
|
|
576
|
+
expect(values[values.length - 1]).toEqual([2, 4])
|
|
1048
577
|
|
|
1049
|
-
|
|
578
|
+
list.add(3)
|
|
579
|
+
await wait(20)
|
|
580
|
+
expect(values[values.length - 1]).toEqual([2, 4, 6])
|
|
1050
581
|
})
|
|
1051
582
|
|
|
1052
|
-
test('
|
|
1053
|
-
const
|
|
1054
|
-
const doubled =
|
|
583
|
+
test('should chain from collection', () => {
|
|
584
|
+
const list = createList([1, 2, 3])
|
|
585
|
+
const doubled = list.deriveCollection((v: number) => v * 2)
|
|
586
|
+
const quadrupled = doubled.deriveCollection((v: number) => v * 2)
|
|
1055
587
|
|
|
1056
|
-
|
|
1057
|
-
let collectionUnwatchCalled = false
|
|
588
|
+
expect(quadrupled.get()).toEqual([4, 8, 12])
|
|
1058
589
|
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
return () => {
|
|
1062
|
-
collectionUnwatchCalled = true
|
|
1063
|
-
}
|
|
1064
|
-
})
|
|
1065
|
-
|
|
1066
|
-
// Access via collection.length - this should trigger collection's HOOK_WATCH
|
|
1067
|
-
let effectValue: number = 0
|
|
1068
|
-
const cleanup = createEffect(() => {
|
|
1069
|
-
effectValue = doubled.length
|
|
1070
|
-
})
|
|
1071
|
-
|
|
1072
|
-
expect(collectionHookWatchCalled).toBe(true)
|
|
1073
|
-
expect(effectValue).toBe(3)
|
|
1074
|
-
expect(collectionUnwatchCalled).toBe(false)
|
|
1075
|
-
|
|
1076
|
-
cleanup()
|
|
1077
|
-
expect(collectionUnwatchCalled).toBe(true)
|
|
590
|
+
list.add(4)
|
|
591
|
+
expect(quadrupled.get()).toEqual([4, 8, 12, 16])
|
|
1078
592
|
})
|
|
1079
593
|
|
|
1080
|
-
test('
|
|
1081
|
-
const
|
|
1082
|
-
const doubled =
|
|
1083
|
-
const quadrupled = doubled.deriveCollection(x => x * 2)
|
|
1084
|
-
|
|
1085
|
-
// Set up hook on original source item
|
|
1086
|
-
// biome-ignore lint/style/noNonNullAssertion: test
|
|
1087
|
-
const sourceItem = numbers.at(0)!
|
|
1088
|
-
let sourceHookCalled = false
|
|
1089
|
-
|
|
1090
|
-
sourceItem.on('watch', () => {
|
|
1091
|
-
sourceHookCalled = true
|
|
1092
|
-
return () => {
|
|
1093
|
-
// Chained computed signals manage cleanup through dependency chain
|
|
1094
|
-
}
|
|
1095
|
-
})
|
|
594
|
+
test('should chain from createCollection source', () => {
|
|
595
|
+
const col = createCollection(() => () => {}, { value: [1, 2, 3] })
|
|
596
|
+
const doubled = col.deriveCollection((v: number) => v * 2)
|
|
1096
597
|
|
|
1097
|
-
expect(
|
|
598
|
+
expect(doubled.get()).toEqual([2, 4, 6])
|
|
599
|
+
expect(isCollection(doubled)).toBe(true)
|
|
600
|
+
})
|
|
1098
601
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
602
|
+
test('should propagate errors from per-item memos', () => {
|
|
603
|
+
const list = createList([1, 2, 3])
|
|
604
|
+
const mapped = list.deriveCollection((v: number) => {
|
|
605
|
+
if (v === 2) throw new Error('bad item')
|
|
606
|
+
return v * 2
|
|
1104
607
|
})
|
|
1105
608
|
|
|
1106
|
-
expect(
|
|
1107
|
-
expect(effectValue).toBe(8) // 2 * 2 * 2
|
|
1108
|
-
|
|
1109
|
-
cleanup()
|
|
609
|
+
expect(() => mapped.get()).toThrow('bad item')
|
|
1110
610
|
})
|
|
1111
611
|
})
|
|
1112
612
|
})
|