@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/src/classes/computed.ts
DELETED
|
@@ -1,392 +0,0 @@
|
|
|
1
|
-
import { isEqual } from '../diff'
|
|
2
|
-
import {
|
|
3
|
-
CircularDependencyError,
|
|
4
|
-
createError,
|
|
5
|
-
InvalidHookError,
|
|
6
|
-
validateCallback,
|
|
7
|
-
validateSignalValue,
|
|
8
|
-
} from '../errors'
|
|
9
|
-
import {
|
|
10
|
-
type Cleanup,
|
|
11
|
-
createWatcher,
|
|
12
|
-
flushPendingReactions,
|
|
13
|
-
HOOK_CLEANUP,
|
|
14
|
-
HOOK_WATCH,
|
|
15
|
-
type HookCallback,
|
|
16
|
-
notifyWatchers,
|
|
17
|
-
subscribeActiveWatcher,
|
|
18
|
-
trackSignalReads,
|
|
19
|
-
UNSET,
|
|
20
|
-
type Watcher,
|
|
21
|
-
type WatchHook,
|
|
22
|
-
} from '../system'
|
|
23
|
-
import {
|
|
24
|
-
isAbortError,
|
|
25
|
-
isAsyncFunction,
|
|
26
|
-
isObjectOfType,
|
|
27
|
-
isSyncFunction,
|
|
28
|
-
} from '../util'
|
|
29
|
-
|
|
30
|
-
/* === Types === */
|
|
31
|
-
|
|
32
|
-
type Computed<T extends {}> = {
|
|
33
|
-
readonly [Symbol.toStringTag]: 'Computed'
|
|
34
|
-
get(): T
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
type MemoCallback<T extends {} & { then?: undefined }> = (oldValue: T) => T
|
|
38
|
-
|
|
39
|
-
type TaskCallback<T extends {} & { then?: undefined }> = (
|
|
40
|
-
oldValue: T,
|
|
41
|
-
abort: AbortSignal,
|
|
42
|
-
) => Promise<T>
|
|
43
|
-
|
|
44
|
-
/* === Constants === */
|
|
45
|
-
|
|
46
|
-
const TYPE_COMPUTED = 'Computed' as const
|
|
47
|
-
|
|
48
|
-
/* === Classes === */
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Create a new memoized signal for a synchronous function.
|
|
52
|
-
*
|
|
53
|
-
* @since 0.17.0
|
|
54
|
-
*/
|
|
55
|
-
class Memo<T extends {}> {
|
|
56
|
-
#watchers: Set<Watcher> = new Set()
|
|
57
|
-
#callback: MemoCallback<T>
|
|
58
|
-
#value: T
|
|
59
|
-
#error: Error | undefined
|
|
60
|
-
#dirty = true
|
|
61
|
-
#computing = false
|
|
62
|
-
#watcher: Watcher | undefined
|
|
63
|
-
#watchHookCallbacks: Set<HookCallback> | undefined
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Create a new memoized signal.
|
|
67
|
-
*
|
|
68
|
-
* @param {MemoCallback<T>} callback - Callback function to compute the memoized value
|
|
69
|
-
* @param {T} [initialValue = UNSET] - Initial value of the signal
|
|
70
|
-
* @throws {InvalidCallbackError} If the callback is not an sync function
|
|
71
|
-
* @throws {InvalidSignalValueError} If the initial value is not valid
|
|
72
|
-
*/
|
|
73
|
-
constructor(callback: MemoCallback<T>, initialValue: T = UNSET) {
|
|
74
|
-
validateCallback(this.constructor.name, callback, isMemoCallback)
|
|
75
|
-
validateSignalValue(this.constructor.name, initialValue)
|
|
76
|
-
|
|
77
|
-
this.#callback = callback
|
|
78
|
-
this.#value = initialValue
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
#getWatcher(): Watcher {
|
|
82
|
-
if (!this.#watcher) {
|
|
83
|
-
// Own watcher: called by notifyWatchers() in upstream signals (push)
|
|
84
|
-
this.#watcher = createWatcher(() => {
|
|
85
|
-
this.#dirty = true
|
|
86
|
-
if (!notifyWatchers(this.#watchers)) this.#watcher?.stop()
|
|
87
|
-
})
|
|
88
|
-
this.#watcher.on(HOOK_CLEANUP, () => {
|
|
89
|
-
this.#watcher = undefined
|
|
90
|
-
})
|
|
91
|
-
}
|
|
92
|
-
return this.#watcher
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
get [Symbol.toStringTag](): 'Computed' {
|
|
96
|
-
return TYPE_COMPUTED
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Return the memoized value after computing it if necessary.
|
|
101
|
-
*
|
|
102
|
-
* @returns {T}
|
|
103
|
-
* @throws {CircularDependencyError} If a circular dependency is detected
|
|
104
|
-
* @throws {Error} If an error occurs during computation
|
|
105
|
-
*/
|
|
106
|
-
get(): T {
|
|
107
|
-
subscribeActiveWatcher(this.#watchers, this.#watchHookCallbacks)
|
|
108
|
-
flushPendingReactions()
|
|
109
|
-
|
|
110
|
-
if (this.#dirty) {
|
|
111
|
-
const watcher = this.#getWatcher()
|
|
112
|
-
trackSignalReads(watcher, () => {
|
|
113
|
-
if (this.#computing) throw new CircularDependencyError('memo')
|
|
114
|
-
|
|
115
|
-
let result: T
|
|
116
|
-
this.#computing = true
|
|
117
|
-
try {
|
|
118
|
-
result = this.#callback(this.#value)
|
|
119
|
-
} catch (e) {
|
|
120
|
-
// Err track
|
|
121
|
-
this.#value = UNSET
|
|
122
|
-
this.#error = createError(e)
|
|
123
|
-
this.#computing = false
|
|
124
|
-
return
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (null == result || UNSET === result) {
|
|
128
|
-
// Nil track
|
|
129
|
-
this.#value = UNSET
|
|
130
|
-
this.#error = undefined
|
|
131
|
-
} else {
|
|
132
|
-
// Ok track
|
|
133
|
-
this.#value = result
|
|
134
|
-
this.#error = undefined
|
|
135
|
-
this.#dirty = false
|
|
136
|
-
}
|
|
137
|
-
this.#computing = false
|
|
138
|
-
})
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (this.#error) throw this.#error
|
|
142
|
-
return this.#value
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Register a callback to be called when HOOK_WATCH is triggered.
|
|
147
|
-
*
|
|
148
|
-
* @param {WatchHook} type - The type of hook to register the callback for; only HOOK_WATCH is supported
|
|
149
|
-
* @param {HookCallback} callback - The callback to register
|
|
150
|
-
* @returns {Cleanup} - A function to unregister the callback
|
|
151
|
-
*/
|
|
152
|
-
on(type: WatchHook, callback: HookCallback): Cleanup {
|
|
153
|
-
if (type === HOOK_WATCH) {
|
|
154
|
-
this.#watchHookCallbacks ||= new Set()
|
|
155
|
-
this.#watchHookCallbacks.add(callback)
|
|
156
|
-
return () => {
|
|
157
|
-
this.#watchHookCallbacks?.delete(callback)
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
throw new InvalidHookError(this.constructor.name, type)
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Create a new task signals that memoizes the result of an asynchronous function.
|
|
166
|
-
*
|
|
167
|
-
* @since 0.17.0
|
|
168
|
-
*/
|
|
169
|
-
class Task<T extends {}> {
|
|
170
|
-
#watchers: Set<Watcher> = new Set()
|
|
171
|
-
#callback: TaskCallback<T>
|
|
172
|
-
#value: T
|
|
173
|
-
#error: Error | undefined
|
|
174
|
-
#dirty = true
|
|
175
|
-
#computing = false
|
|
176
|
-
#changed = false
|
|
177
|
-
#watcher: Watcher | undefined
|
|
178
|
-
#controller: AbortController | undefined
|
|
179
|
-
#watchHookCallbacks: Set<HookCallback> | undefined
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Create a new task signal for an asynchronous function.
|
|
183
|
-
*
|
|
184
|
-
* @param {TaskCallback<T>} callback - The asynchronous function to compute the memoized value
|
|
185
|
-
* @param {T} [initialValue = UNSET] - Initial value of the signal
|
|
186
|
-
* @throws {InvalidCallbackError} If the callback is not an async function
|
|
187
|
-
* @throws {InvalidSignalValueError} If the initial value is not valid
|
|
188
|
-
*/
|
|
189
|
-
constructor(callback: TaskCallback<T>, initialValue: T = UNSET) {
|
|
190
|
-
validateCallback(this.constructor.name, callback, isTaskCallback)
|
|
191
|
-
validateSignalValue(this.constructor.name, initialValue)
|
|
192
|
-
|
|
193
|
-
this.#callback = callback
|
|
194
|
-
this.#value = initialValue
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
#getWatcher(): Watcher {
|
|
198
|
-
if (!this.#watcher) {
|
|
199
|
-
// Own watcher: called by notifyWatchers() in upstream signals (push)
|
|
200
|
-
this.#watcher = createWatcher(() => {
|
|
201
|
-
this.#dirty = true
|
|
202
|
-
this.#controller?.abort()
|
|
203
|
-
if (!notifyWatchers(this.#watchers)) this.#watcher?.stop()
|
|
204
|
-
})
|
|
205
|
-
this.#watcher.on(HOOK_CLEANUP, () => {
|
|
206
|
-
this.#controller?.abort()
|
|
207
|
-
this.#controller = undefined
|
|
208
|
-
this.#watcher = undefined
|
|
209
|
-
})
|
|
210
|
-
}
|
|
211
|
-
return this.#watcher
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
get [Symbol.toStringTag](): 'Computed' {
|
|
215
|
-
return TYPE_COMPUTED
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Return the memoized value after executing the async function if necessary.
|
|
220
|
-
*
|
|
221
|
-
* @returns {T}
|
|
222
|
-
* @throws {CircularDependencyError} If a circular dependency is detected
|
|
223
|
-
* @throws {Error} If an error occurs during computation
|
|
224
|
-
*/
|
|
225
|
-
get(): T {
|
|
226
|
-
subscribeActiveWatcher(this.#watchers, this.#watchHookCallbacks)
|
|
227
|
-
flushPendingReactions()
|
|
228
|
-
|
|
229
|
-
// Functions to update internal state
|
|
230
|
-
const ok = (v: T): undefined => {
|
|
231
|
-
if (!isEqual(v, this.#value)) {
|
|
232
|
-
this.#value = v
|
|
233
|
-
this.#changed = true
|
|
234
|
-
}
|
|
235
|
-
this.#error = undefined
|
|
236
|
-
this.#dirty = false
|
|
237
|
-
}
|
|
238
|
-
const nil = (): undefined => {
|
|
239
|
-
this.#changed = UNSET !== this.#value
|
|
240
|
-
this.#value = UNSET
|
|
241
|
-
this.#error = undefined
|
|
242
|
-
}
|
|
243
|
-
const err = (e: unknown): undefined => {
|
|
244
|
-
const newError = createError(e)
|
|
245
|
-
this.#changed =
|
|
246
|
-
!this.#error ||
|
|
247
|
-
newError.name !== this.#error.name ||
|
|
248
|
-
newError.message !== this.#error.message
|
|
249
|
-
this.#value = UNSET
|
|
250
|
-
this.#error = newError
|
|
251
|
-
}
|
|
252
|
-
const settle =
|
|
253
|
-
<T>(fn: (arg: T) => void) =>
|
|
254
|
-
(arg: T) => {
|
|
255
|
-
this.#computing = false
|
|
256
|
-
this.#controller = undefined
|
|
257
|
-
fn(arg)
|
|
258
|
-
if (this.#changed && !notifyWatchers(this.#watchers))
|
|
259
|
-
this.#watcher?.stop()
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const compute = () =>
|
|
263
|
-
trackSignalReads(this.#getWatcher(), () => {
|
|
264
|
-
if (this.#computing) throw new CircularDependencyError('task')
|
|
265
|
-
this.#changed = false
|
|
266
|
-
|
|
267
|
-
// Return current value until promise resolves
|
|
268
|
-
if (this.#controller) return this.#value
|
|
269
|
-
|
|
270
|
-
this.#controller = new AbortController()
|
|
271
|
-
this.#controller.signal.addEventListener(
|
|
272
|
-
'abort',
|
|
273
|
-
() => {
|
|
274
|
-
this.#computing = false
|
|
275
|
-
this.#controller = undefined
|
|
276
|
-
|
|
277
|
-
// Retry computation with updated state
|
|
278
|
-
compute()
|
|
279
|
-
},
|
|
280
|
-
{
|
|
281
|
-
once: true,
|
|
282
|
-
},
|
|
283
|
-
)
|
|
284
|
-
let result: Promise<T>
|
|
285
|
-
this.#computing = true
|
|
286
|
-
try {
|
|
287
|
-
result = this.#callback(
|
|
288
|
-
this.#value,
|
|
289
|
-
this.#controller.signal,
|
|
290
|
-
)
|
|
291
|
-
} catch (e) {
|
|
292
|
-
if (isAbortError(e)) nil()
|
|
293
|
-
else err(e)
|
|
294
|
-
this.#computing = false
|
|
295
|
-
return
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
if (result instanceof Promise)
|
|
299
|
-
result.then(settle(ok), settle(err))
|
|
300
|
-
else if (null == result || UNSET === result) nil()
|
|
301
|
-
else ok(result)
|
|
302
|
-
this.#computing = false
|
|
303
|
-
})
|
|
304
|
-
|
|
305
|
-
if (this.#dirty) compute()
|
|
306
|
-
|
|
307
|
-
if (this.#error) throw this.#error
|
|
308
|
-
return this.#value
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* Register a callback to be called when HOOK_WATCH is triggered.
|
|
313
|
-
*
|
|
314
|
-
* @param {WatchHook} type - The type of hook to register the callback for; only HOOK_WATCH is supported
|
|
315
|
-
* @param {HookCallback} callback - The callback to register
|
|
316
|
-
* @returns {Cleanup} - A function to unregister the callback
|
|
317
|
-
*/
|
|
318
|
-
on(type: WatchHook, callback: HookCallback): Cleanup {
|
|
319
|
-
if (type === HOOK_WATCH) {
|
|
320
|
-
this.#watchHookCallbacks ||= new Set()
|
|
321
|
-
this.#watchHookCallbacks.add(callback)
|
|
322
|
-
return () => {
|
|
323
|
-
this.#watchHookCallbacks?.delete(callback)
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
throw new InvalidHookError(this.constructor.name, type)
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/* === Functions === */
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* Create a derived signal from existing signals
|
|
334
|
-
*
|
|
335
|
-
* @since 0.9.0
|
|
336
|
-
* @param {MemoCallback<T> | TaskCallback<T>} callback - Computation callback function
|
|
337
|
-
*/
|
|
338
|
-
const createComputed = <T extends {}>(
|
|
339
|
-
callback: TaskCallback<T> | MemoCallback<T>,
|
|
340
|
-
initialValue: T = UNSET,
|
|
341
|
-
) =>
|
|
342
|
-
isAsyncFunction(callback)
|
|
343
|
-
? new Task(callback as TaskCallback<T>, initialValue)
|
|
344
|
-
: new Memo(callback as MemoCallback<T>, initialValue)
|
|
345
|
-
|
|
346
|
-
/**
|
|
347
|
-
* Check if a value is a computed signal
|
|
348
|
-
*
|
|
349
|
-
* @since 0.9.0
|
|
350
|
-
* @param {unknown} value - Value to check
|
|
351
|
-
* @returns {boolean} - True if value is a computed signal, false otherwise
|
|
352
|
-
*/
|
|
353
|
-
const isComputed = /*#__PURE__*/ <T extends {}>(
|
|
354
|
-
value: unknown,
|
|
355
|
-
): value is Memo<T> => isObjectOfType(value, TYPE_COMPUTED)
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* Check if the provided value is a callback that may be used as input for createSignal() to derive a computed state
|
|
359
|
-
*
|
|
360
|
-
* @since 0.12.0
|
|
361
|
-
* @param {unknown} value - Value to check
|
|
362
|
-
* @returns {boolean} - True if value is a sync callback, false otherwise
|
|
363
|
-
*/
|
|
364
|
-
const isMemoCallback = /*#__PURE__*/ <T extends {} & { then?: undefined }>(
|
|
365
|
-
value: unknown,
|
|
366
|
-
): value is MemoCallback<T> => isSyncFunction(value) && value.length < 2
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* Check if the provided value is a callback that may be used as input for createSignal() to derive a computed state
|
|
370
|
-
*
|
|
371
|
-
* @since 0.17.0
|
|
372
|
-
* @param {unknown} value - Value to check
|
|
373
|
-
* @returns {boolean} - True if value is an async callback, false otherwise
|
|
374
|
-
*/
|
|
375
|
-
const isTaskCallback = /*#__PURE__*/ <T extends {}>(
|
|
376
|
-
value: unknown,
|
|
377
|
-
): value is TaskCallback<T> => isAsyncFunction(value) && value.length < 3
|
|
378
|
-
|
|
379
|
-
/* === Exports === */
|
|
380
|
-
|
|
381
|
-
export {
|
|
382
|
-
TYPE_COMPUTED,
|
|
383
|
-
createComputed,
|
|
384
|
-
isComputed,
|
|
385
|
-
isMemoCallback,
|
|
386
|
-
isTaskCallback,
|
|
387
|
-
Memo,
|
|
388
|
-
Task,
|
|
389
|
-
type Computed,
|
|
390
|
-
type MemoCallback,
|
|
391
|
-
type TaskCallback,
|
|
392
|
-
}
|
package/src/classes/list.ts
DELETED
|
@@ -1,310 +0,0 @@
|
|
|
1
|
-
import { diff, isEqual, type UnknownArray } from '../diff'
|
|
2
|
-
import {
|
|
3
|
-
DuplicateKeyError,
|
|
4
|
-
InvalidHookError,
|
|
5
|
-
validateSignalValue,
|
|
6
|
-
} from '../errors'
|
|
7
|
-
import {
|
|
8
|
-
type Cleanup,
|
|
9
|
-
HOOK_ADD,
|
|
10
|
-
HOOK_CHANGE,
|
|
11
|
-
HOOK_REMOVE,
|
|
12
|
-
HOOK_SORT,
|
|
13
|
-
HOOK_WATCH,
|
|
14
|
-
type Hook,
|
|
15
|
-
type HookCallback,
|
|
16
|
-
type HookCallbacks,
|
|
17
|
-
isHandledHook,
|
|
18
|
-
notifyWatchers,
|
|
19
|
-
subscribeActiveWatcher,
|
|
20
|
-
triggerHook,
|
|
21
|
-
UNSET,
|
|
22
|
-
type Watcher,
|
|
23
|
-
} from '../system'
|
|
24
|
-
import { isFunction, isNumber, isObjectOfType, isString } from '../util'
|
|
25
|
-
import { type CollectionCallback, DerivedCollection } from './collection'
|
|
26
|
-
import { Composite } from './composite'
|
|
27
|
-
import { State } from './state'
|
|
28
|
-
|
|
29
|
-
/* === Types === */
|
|
30
|
-
|
|
31
|
-
type ArrayToRecord<T extends UnknownArray> = {
|
|
32
|
-
[key: string]: T extends Array<infer U extends {}> ? U : never
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
type KeyConfig<T> = string | ((item: T) => string)
|
|
36
|
-
|
|
37
|
-
/* === Constants === */
|
|
38
|
-
|
|
39
|
-
const TYPE_LIST = 'List' as const
|
|
40
|
-
|
|
41
|
-
/* === Class === */
|
|
42
|
-
|
|
43
|
-
class List<T extends {}> {
|
|
44
|
-
#composite: Composite<Record<string, T>, State<T>>
|
|
45
|
-
#watchers = new Set<Watcher>()
|
|
46
|
-
#hookCallbacks: HookCallbacks = {}
|
|
47
|
-
#order: string[] = []
|
|
48
|
-
#generateKey: (item: T) => string
|
|
49
|
-
|
|
50
|
-
constructor(initialValue: T[], keyConfig?: KeyConfig<T>) {
|
|
51
|
-
validateSignalValue(TYPE_LIST, initialValue, Array.isArray)
|
|
52
|
-
|
|
53
|
-
let keyCounter = 0
|
|
54
|
-
this.#generateKey = isString(keyConfig)
|
|
55
|
-
? () => `${keyConfig}${keyCounter++}`
|
|
56
|
-
: isFunction<string>(keyConfig)
|
|
57
|
-
? (item: T) => keyConfig(item)
|
|
58
|
-
: () => String(keyCounter++)
|
|
59
|
-
|
|
60
|
-
this.#composite = new Composite<ArrayToRecord<T[]>, State<T>>(
|
|
61
|
-
this.#toRecord(initialValue),
|
|
62
|
-
(key: string, value: unknown): value is T => {
|
|
63
|
-
validateSignalValue(`${TYPE_LIST} for key "${key}"`, value)
|
|
64
|
-
return true
|
|
65
|
-
},
|
|
66
|
-
value => new State(value),
|
|
67
|
-
)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Convert array to record with stable keys
|
|
71
|
-
#toRecord(array: T[]): ArrayToRecord<T[]> {
|
|
72
|
-
const record = {} as Record<string, T>
|
|
73
|
-
|
|
74
|
-
for (let i = 0; i < array.length; i++) {
|
|
75
|
-
const value = array[i]
|
|
76
|
-
if (value === undefined) continue // Skip sparse array positions
|
|
77
|
-
|
|
78
|
-
let key = this.#order[i]
|
|
79
|
-
if (!key) {
|
|
80
|
-
key = this.#generateKey(value)
|
|
81
|
-
this.#order[i] = key
|
|
82
|
-
}
|
|
83
|
-
record[key] = value
|
|
84
|
-
}
|
|
85
|
-
return record
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
get #value(): T[] {
|
|
89
|
-
return this.#order
|
|
90
|
-
.map(key => this.#composite.signals.get(key)?.get())
|
|
91
|
-
.filter(v => v !== undefined) as T[]
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Public methods
|
|
95
|
-
get [Symbol.toStringTag](): 'List' {
|
|
96
|
-
return TYPE_LIST
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
get [Symbol.isConcatSpreadable](): true {
|
|
100
|
-
return true
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
*[Symbol.iterator](): IterableIterator<State<T>> {
|
|
104
|
-
for (const key of this.#order) {
|
|
105
|
-
const signal = this.#composite.signals.get(key)
|
|
106
|
-
if (signal) yield signal as State<T>
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
get length(): number {
|
|
111
|
-
subscribeActiveWatcher(this.#watchers, this.#hookCallbacks[HOOK_WATCH])
|
|
112
|
-
return this.#order.length
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
get(): T[] {
|
|
116
|
-
subscribeActiveWatcher(this.#watchers, this.#hookCallbacks[HOOK_WATCH])
|
|
117
|
-
return this.#value
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
set(newValue: T[]): void {
|
|
121
|
-
if (UNSET === newValue) {
|
|
122
|
-
this.#composite.clear()
|
|
123
|
-
notifyWatchers(this.#watchers)
|
|
124
|
-
this.#watchers.clear()
|
|
125
|
-
return
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const oldValue = this.#value
|
|
129
|
-
const changes = diff(this.#toRecord(oldValue), this.#toRecord(newValue))
|
|
130
|
-
const removedKeys = Object.keys(changes.remove)
|
|
131
|
-
|
|
132
|
-
const changed = this.#composite.change(changes)
|
|
133
|
-
if (changed) {
|
|
134
|
-
for (const key of removedKeys) {
|
|
135
|
-
const index = this.#order.indexOf(key)
|
|
136
|
-
if (index !== -1) this.#order.splice(index, 1)
|
|
137
|
-
}
|
|
138
|
-
this.#order = this.#order.filter(() => true)
|
|
139
|
-
notifyWatchers(this.#watchers)
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
update(fn: (oldValue: T[]) => T[]): void {
|
|
144
|
-
this.set(fn(this.get()))
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
at(index: number): State<T> | undefined {
|
|
148
|
-
return this.#composite.signals.get(this.#order[index])
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
keys(): IterableIterator<string> {
|
|
152
|
-
return this.#order.values()
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
byKey(key: string): State<T> | undefined {
|
|
156
|
-
return this.#composite.signals.get(key)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
keyAt(index: number): string | undefined {
|
|
160
|
-
return this.#order[index]
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
indexOfKey(key: string): number {
|
|
164
|
-
return this.#order.indexOf(key)
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
add(value: T): string {
|
|
168
|
-
const key = this.#generateKey(value)
|
|
169
|
-
if (this.#composite.signals.has(key))
|
|
170
|
-
throw new DuplicateKeyError('store', key, value)
|
|
171
|
-
|
|
172
|
-
if (!this.#order.includes(key)) this.#order.push(key)
|
|
173
|
-
const ok = this.#composite.add(key, value)
|
|
174
|
-
if (ok) notifyWatchers(this.#watchers)
|
|
175
|
-
return key
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
remove(keyOrIndex: string | number): void {
|
|
179
|
-
const key = isNumber(keyOrIndex) ? this.#order[keyOrIndex] : keyOrIndex
|
|
180
|
-
const ok = this.#composite.remove(key)
|
|
181
|
-
if (ok) {
|
|
182
|
-
const index = isNumber(keyOrIndex)
|
|
183
|
-
? keyOrIndex
|
|
184
|
-
: this.#order.indexOf(key)
|
|
185
|
-
if (index >= 0) this.#order.splice(index, 1)
|
|
186
|
-
this.#order = this.#order.filter(() => true)
|
|
187
|
-
notifyWatchers(this.#watchers)
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
sort(compareFn?: (a: T, b: T) => number): void {
|
|
192
|
-
const entries = this.#order
|
|
193
|
-
.map(
|
|
194
|
-
key =>
|
|
195
|
-
[key, this.#composite.signals.get(key)?.get()] as [
|
|
196
|
-
string,
|
|
197
|
-
T,
|
|
198
|
-
],
|
|
199
|
-
)
|
|
200
|
-
.sort(
|
|
201
|
-
isFunction(compareFn)
|
|
202
|
-
? (a, b) => compareFn(a[1], b[1])
|
|
203
|
-
: (a, b) => String(a[1]).localeCompare(String(b[1])),
|
|
204
|
-
)
|
|
205
|
-
const newOrder = entries.map(([key]) => key)
|
|
206
|
-
|
|
207
|
-
if (!isEqual(this.#order, newOrder)) {
|
|
208
|
-
this.#order = newOrder
|
|
209
|
-
notifyWatchers(this.#watchers)
|
|
210
|
-
triggerHook(this.#hookCallbacks.sort, this.#order)
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
splice(start: number, deleteCount?: number, ...items: T[]): T[] {
|
|
215
|
-
const length = this.#order.length
|
|
216
|
-
const actualStart =
|
|
217
|
-
start < 0 ? Math.max(0, length + start) : Math.min(start, length)
|
|
218
|
-
const actualDeleteCount = Math.max(
|
|
219
|
-
0,
|
|
220
|
-
Math.min(
|
|
221
|
-
deleteCount ?? Math.max(0, length - Math.max(0, actualStart)),
|
|
222
|
-
length - actualStart,
|
|
223
|
-
),
|
|
224
|
-
)
|
|
225
|
-
|
|
226
|
-
const add = {} as Record<string, T>
|
|
227
|
-
const remove = {} as Record<string, T>
|
|
228
|
-
|
|
229
|
-
// Collect items to delete and their keys
|
|
230
|
-
for (let i = 0; i < actualDeleteCount; i++) {
|
|
231
|
-
const index = actualStart + i
|
|
232
|
-
const key = this.#order[index]
|
|
233
|
-
if (key) {
|
|
234
|
-
const signal = this.#composite.signals.get(key)
|
|
235
|
-
if (signal) remove[key] = signal.get() as T
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Build new order: items before splice point
|
|
240
|
-
const newOrder = this.#order.slice(0, actualStart)
|
|
241
|
-
|
|
242
|
-
// Add new items
|
|
243
|
-
for (const item of items) {
|
|
244
|
-
const key = this.#generateKey(item)
|
|
245
|
-
newOrder.push(key)
|
|
246
|
-
add[key] = item as T
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Add items after splice point
|
|
250
|
-
newOrder.push(...this.#order.slice(actualStart + actualDeleteCount))
|
|
251
|
-
|
|
252
|
-
const changed = !!(
|
|
253
|
-
Object.keys(add).length || Object.keys(remove).length
|
|
254
|
-
)
|
|
255
|
-
|
|
256
|
-
if (changed) {
|
|
257
|
-
this.#composite.change({
|
|
258
|
-
add,
|
|
259
|
-
change: {} as Record<string, T>,
|
|
260
|
-
remove,
|
|
261
|
-
changed,
|
|
262
|
-
})
|
|
263
|
-
this.#order = newOrder.filter(() => true) // Update order array
|
|
264
|
-
notifyWatchers(this.#watchers)
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
return Object.values(remove)
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
on(type: Hook, callback: HookCallback): Cleanup {
|
|
271
|
-
if (isHandledHook(type, [HOOK_SORT, HOOK_WATCH])) {
|
|
272
|
-
this.#hookCallbacks[type] ||= new Set<HookCallback>()
|
|
273
|
-
this.#hookCallbacks[type].add(callback)
|
|
274
|
-
return () => {
|
|
275
|
-
this.#hookCallbacks[type]?.delete(callback)
|
|
276
|
-
}
|
|
277
|
-
} else if (isHandledHook(type, [HOOK_ADD, HOOK_CHANGE, HOOK_REMOVE])) {
|
|
278
|
-
return this.#composite.on(type, callback)
|
|
279
|
-
}
|
|
280
|
-
throw new InvalidHookError(TYPE_LIST, type)
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
deriveCollection<R extends {}>(
|
|
284
|
-
callback: (sourceValue: T) => R,
|
|
285
|
-
): DerivedCollection<R, T>
|
|
286
|
-
deriveCollection<R extends {}>(
|
|
287
|
-
callback: (sourceValue: T, abort: AbortSignal) => Promise<R>,
|
|
288
|
-
): DerivedCollection<R, T>
|
|
289
|
-
deriveCollection<R extends {}>(
|
|
290
|
-
callback: CollectionCallback<R, T>,
|
|
291
|
-
): DerivedCollection<R, T> {
|
|
292
|
-
return new DerivedCollection(this, callback)
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/* === Functions === */
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Check if the provided value is a List instance
|
|
300
|
-
*
|
|
301
|
-
* @since 0.15.0
|
|
302
|
-
* @param {unknown} value - Value to check
|
|
303
|
-
* @returns {boolean} - True if the value is a List instance, false otherwise
|
|
304
|
-
*/
|
|
305
|
-
const isList = <T extends {}>(value: unknown): value is List<T> =>
|
|
306
|
-
isObjectOfType(value, TYPE_LIST)
|
|
307
|
-
|
|
308
|
-
/* === Exports === */
|
|
309
|
-
|
|
310
|
-
export { isList, List, TYPE_LIST, type ArrayToRecord, type KeyConfig }
|