@zeix/cause-effect 0.17.3 → 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 -232
- package/.cursorrules +41 -35
- package/.github/copilot-instructions.md +166 -116
- package/ARCHITECTURE.md +274 -0
- package/CLAUDE.md +199 -143
- package/COLLECTION_REFACTORING.md +161 -0
- package/GUIDE.md +298 -0
- package/README.md +232 -197
- package/REQUIREMENTS.md +100 -0
- package/bench/reactivity.bench.ts +577 -0
- package/index.dev.js +1325 -997
- package/index.js +1 -1
- package/index.ts +58 -74
- package/package.json +4 -1
- package/src/errors.ts +118 -74
- 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 -66
- package/src/util.ts +26 -57
- package/test/batch.test.ts +96 -62
- package/test/benchmark.test.ts +473 -487
- package/test/collection.test.ts +466 -706
- package/test/effect.test.ts +293 -696
- package/test/list.test.ts +335 -592
- 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 -265
- package/test/store.test.ts +346 -446
- package/test/task.test.ts +395 -0
- package/test/untrack.test.ts +167 -0
- package/types/index.d.ts +13 -15
- package/types/src/errors.d.ts +73 -17
- 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 -29
- package/types/src/util.d.ts +9 -16
- package/archive/benchmark.ts +0 -683
- package/archive/collection.ts +0 -253
- package/archive/composite.ts +0 -85
- package/archive/computed.ts +0 -195
- package/archive/list.ts +0 -483
- package/archive/memo.ts +0 -139
- package/archive/state.ts +0 -90
- package/archive/store.ts +0 -298
- package/archive/task.ts +0 -189
- package/src/classes/collection.ts +0 -245
- package/src/classes/computed.ts +0 -349
- package/src/classes/list.ts +0 -343
- package/src/classes/ref.ts +0 -70
- package/src/classes/state.ts +0 -102
- package/src/classes/store.ts +0 -262
- package/src/diff.ts +0 -138
- package/src/effect.ts +0 -93
- package/src/match.ts +0 -45
- package/src/resolve.ts +0 -49
- package/src/system.ts +0 -257
- package/test/computed.test.ts +0 -1108
- package/test/diff.test.ts +0 -955
- package/test/match.test.ts +0 -388
- package/test/ref.test.ts +0 -353
- package/test/resolve.test.ts +0 -154
- package/types/src/classes/collection.d.ts +0 -45
- package/types/src/classes/computed.d.ts +0 -94
- package/types/src/classes/list.d.ts +0 -43
- package/types/src/classes/ref.d.ts +0 -35
- package/types/src/classes/state.d.ts +0 -49
- package/types/src/classes/store.d.ts +0 -52
- 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 -78
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import {
|
|
2
|
+
UnsetSignalValueError,
|
|
3
|
+
validateCallback,
|
|
4
|
+
validateSignalValue,
|
|
5
|
+
} from '../errors'
|
|
6
|
+
import {
|
|
7
|
+
activeSink,
|
|
8
|
+
batch,
|
|
9
|
+
type Cleanup,
|
|
10
|
+
FLAG_CLEAN,
|
|
11
|
+
FLAG_DIRTY,
|
|
12
|
+
link,
|
|
13
|
+
type MemoNode,
|
|
14
|
+
propagate,
|
|
15
|
+
refresh,
|
|
16
|
+
type Signal,
|
|
17
|
+
type SinkNode,
|
|
18
|
+
TYPE_COLLECTION,
|
|
19
|
+
untrack,
|
|
20
|
+
} from '../graph'
|
|
21
|
+
import { isAsyncFunction, isFunction, isObjectOfType } from '../util'
|
|
22
|
+
import {
|
|
23
|
+
type DiffResult,
|
|
24
|
+
isList,
|
|
25
|
+
type KeyConfig,
|
|
26
|
+
keysEqual,
|
|
27
|
+
type List,
|
|
28
|
+
} from './list'
|
|
29
|
+
import { createMemo, type Memo } from './memo'
|
|
30
|
+
import { createState, isState } from './state'
|
|
31
|
+
import { createTask } from './task'
|
|
32
|
+
|
|
33
|
+
/* === Types === */
|
|
34
|
+
|
|
35
|
+
type CollectionSource<T extends {}> = List<T> | Collection<T>
|
|
36
|
+
|
|
37
|
+
type DeriveCollectionCallback<T extends {}, U extends {}> =
|
|
38
|
+
| ((sourceValue: U) => T)
|
|
39
|
+
| ((sourceValue: U, abort: AbortSignal) => Promise<T>)
|
|
40
|
+
|
|
41
|
+
type Collection<T extends {}> = {
|
|
42
|
+
readonly [Symbol.toStringTag]: 'Collection'
|
|
43
|
+
readonly [Symbol.isConcatSpreadable]: true
|
|
44
|
+
[Symbol.iterator](): IterableIterator<Signal<T>>
|
|
45
|
+
keys(): IterableIterator<string>
|
|
46
|
+
get(): T[]
|
|
47
|
+
at(index: number): Signal<T> | undefined
|
|
48
|
+
byKey(key: string): Signal<T> | undefined
|
|
49
|
+
keyAt(index: number): string | undefined
|
|
50
|
+
indexOfKey(key: string): number
|
|
51
|
+
deriveCollection<R extends {}>(
|
|
52
|
+
callback: (sourceValue: T) => R,
|
|
53
|
+
): Collection<R>
|
|
54
|
+
deriveCollection<R extends {}>(
|
|
55
|
+
callback: (sourceValue: T, abort: AbortSignal) => Promise<R>,
|
|
56
|
+
): Collection<R>
|
|
57
|
+
readonly length: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type CollectionOptions<T extends {}> = {
|
|
61
|
+
value?: T[]
|
|
62
|
+
keyConfig?: KeyConfig<T>
|
|
63
|
+
createItem?: (key: string, value: T) => Signal<T>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type CollectionCallback = (
|
|
67
|
+
applyChanges: (changes: DiffResult) => void,
|
|
68
|
+
) => Cleanup
|
|
69
|
+
|
|
70
|
+
/* === Functions === */
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Creates a derived Collection from a List or another Collection with item-level memoization.
|
|
74
|
+
* Sync callbacks use createMemo, async callbacks use createTask.
|
|
75
|
+
* Structural changes are tracked reactively via the source's keys.
|
|
76
|
+
*
|
|
77
|
+
* @since 0.18.0
|
|
78
|
+
* @param source - The source List or Collection to derive from
|
|
79
|
+
* @param callback - Transformation function applied to each item
|
|
80
|
+
* @returns A Collection signal
|
|
81
|
+
*/
|
|
82
|
+
function deriveCollection<T extends {}, U extends {}>(
|
|
83
|
+
source: CollectionSource<U>,
|
|
84
|
+
callback: (sourceValue: U) => T,
|
|
85
|
+
): Collection<T>
|
|
86
|
+
function deriveCollection<T extends {}, U extends {}>(
|
|
87
|
+
source: CollectionSource<U>,
|
|
88
|
+
callback: (sourceValue: U, abort: AbortSignal) => Promise<T>,
|
|
89
|
+
): Collection<T>
|
|
90
|
+
function deriveCollection<T extends {}, U extends {}>(
|
|
91
|
+
source: CollectionSource<U>,
|
|
92
|
+
callback: DeriveCollectionCallback<T, U>,
|
|
93
|
+
): Collection<T> {
|
|
94
|
+
validateCallback(TYPE_COLLECTION, callback)
|
|
95
|
+
if (!isCollectionSource(source))
|
|
96
|
+
throw new TypeError(
|
|
97
|
+
`[${TYPE_COLLECTION}] Invalid collection source: expected a List or Collection`,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const isAsync = isAsyncFunction(callback)
|
|
101
|
+
const signals = new Map<string, Memo<T>>()
|
|
102
|
+
|
|
103
|
+
const addSignal = (key: string): void => {
|
|
104
|
+
const signal = isAsync
|
|
105
|
+
? createTask(async (prev: T | undefined, abort: AbortSignal) => {
|
|
106
|
+
const sourceValue = source.byKey(key)?.get() as U
|
|
107
|
+
if (sourceValue == null) return prev as T
|
|
108
|
+
return (
|
|
109
|
+
callback as (
|
|
110
|
+
sourceValue: U,
|
|
111
|
+
abort: AbortSignal,
|
|
112
|
+
) => Promise<T>
|
|
113
|
+
)(sourceValue, abort)
|
|
114
|
+
})
|
|
115
|
+
: createMemo(() => {
|
|
116
|
+
const sourceValue = source.byKey(key)?.get() as U
|
|
117
|
+
if (sourceValue == null) return undefined as unknown as T
|
|
118
|
+
return (callback as (sourceValue: U) => T)(sourceValue)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
signals.set(key, signal as Memo<T>)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Sync collection signals with source keys, reading source.keys()
|
|
125
|
+
// to establish a graph edge from source → this node.
|
|
126
|
+
// Intentionally side-effectful: mutates the private signals map inside what
|
|
127
|
+
// is conceptually a MemoNode.fn. The side effects are idempotent and scoped
|
|
128
|
+
// to private state — separating detection from application would add complexity.
|
|
129
|
+
function syncKeys(): string[] {
|
|
130
|
+
const newKeys = Array.from(source.keys())
|
|
131
|
+
const oldKeys = node.value
|
|
132
|
+
|
|
133
|
+
if (!keysEqual(oldKeys, newKeys)) {
|
|
134
|
+
const oldKeySet = new Set(oldKeys)
|
|
135
|
+
const newKeySet = new Set(newKeys)
|
|
136
|
+
|
|
137
|
+
for (const key of oldKeys)
|
|
138
|
+
if (!newKeySet.has(key)) signals.delete(key)
|
|
139
|
+
for (const key of newKeys) if (!oldKeySet.has(key)) addSignal(key)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return newKeys
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Structural tracking node — not a general-purpose Memo.
|
|
146
|
+
// fn (syncKeys) reads source.keys() to detect additions/removals.
|
|
147
|
+
// Value is a string[] of keys, not the collection's actual values.
|
|
148
|
+
const node: MemoNode<string[]> = {
|
|
149
|
+
fn: syncKeys,
|
|
150
|
+
value: [],
|
|
151
|
+
flags: FLAG_DIRTY,
|
|
152
|
+
sources: null,
|
|
153
|
+
sourcesTail: null,
|
|
154
|
+
sinks: null,
|
|
155
|
+
sinksTail: null,
|
|
156
|
+
equals: keysEqual,
|
|
157
|
+
error: undefined,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Ensure keys are synced, using the same pattern as List/Store
|
|
161
|
+
function ensureSynced(): string[] {
|
|
162
|
+
if (node.sources) {
|
|
163
|
+
if (node.flags) {
|
|
164
|
+
node.value = untrack(syncKeys)
|
|
165
|
+
node.flags = FLAG_CLEAN
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
refresh(node as unknown as SinkNode)
|
|
169
|
+
if (node.error) throw node.error
|
|
170
|
+
}
|
|
171
|
+
return node.value
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Initialize signals for current source keys
|
|
175
|
+
const initialKeys = Array.from(source.keys())
|
|
176
|
+
for (const key of initialKeys) addSignal(key)
|
|
177
|
+
node.value = initialKeys
|
|
178
|
+
// Keep FLAG_DIRTY so the first refresh() establishes the edge to the source
|
|
179
|
+
|
|
180
|
+
const collection: Collection<T> = {
|
|
181
|
+
[Symbol.toStringTag]: TYPE_COLLECTION,
|
|
182
|
+
[Symbol.isConcatSpreadable]: true as const,
|
|
183
|
+
|
|
184
|
+
*[Symbol.iterator]() {
|
|
185
|
+
for (const key of node.value) {
|
|
186
|
+
const signal = signals.get(key)
|
|
187
|
+
if (signal) yield signal
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
get length() {
|
|
192
|
+
if (activeSink) link(node, activeSink)
|
|
193
|
+
return ensureSynced().length
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
keys() {
|
|
197
|
+
if (activeSink) link(node, activeSink)
|
|
198
|
+
return ensureSynced().values()
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
get() {
|
|
202
|
+
if (activeSink) link(node, activeSink)
|
|
203
|
+
const currentKeys = ensureSynced()
|
|
204
|
+
const result: T[] = []
|
|
205
|
+
for (const key of currentKeys) {
|
|
206
|
+
try {
|
|
207
|
+
const v = signals.get(key)?.get()
|
|
208
|
+
if (v != null) result.push(v)
|
|
209
|
+
} catch (e) {
|
|
210
|
+
// Skip pending async items; rethrow real errors
|
|
211
|
+
if (!(e instanceof UnsetSignalValueError)) throw e
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return result
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
at(index: number) {
|
|
218
|
+
return signals.get(node.value[index])
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
byKey(key: string) {
|
|
222
|
+
return signals.get(key)
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
keyAt(index: number) {
|
|
226
|
+
return node.value[index]
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
indexOfKey(key: string) {
|
|
230
|
+
return node.value.indexOf(key)
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
deriveCollection<R extends {}>(
|
|
234
|
+
cb: DeriveCollectionCallback<R, T>,
|
|
235
|
+
): Collection<R> {
|
|
236
|
+
return (
|
|
237
|
+
deriveCollection as <T2 extends {}, U2 extends {}>(
|
|
238
|
+
source: CollectionSource<U2>,
|
|
239
|
+
callback: DeriveCollectionCallback<T2, U2>,
|
|
240
|
+
) => Collection<T2>
|
|
241
|
+
)(collection, cb)
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return collection
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Creates an externally-driven Collection with a watched lifecycle.
|
|
250
|
+
* Items are managed by the start callback via `applyChanges(diffResult)`.
|
|
251
|
+
* The collection activates when first accessed by an effect and deactivates when no longer watched.
|
|
252
|
+
*
|
|
253
|
+
* @since 0.18.0
|
|
254
|
+
* @param start - Callback invoked when the collection starts being watched, receives applyChanges helper
|
|
255
|
+
* @param options - Optional configuration including initial value, key generation, and item signal creation
|
|
256
|
+
* @returns A read-only Collection signal
|
|
257
|
+
*/
|
|
258
|
+
function createCollection<T extends {}>(
|
|
259
|
+
start: CollectionCallback,
|
|
260
|
+
options?: CollectionOptions<T>,
|
|
261
|
+
): Collection<T> {
|
|
262
|
+
const initialValue = options?.value ?? []
|
|
263
|
+
if (initialValue.length)
|
|
264
|
+
validateSignalValue(TYPE_COLLECTION, initialValue, Array.isArray)
|
|
265
|
+
validateCallback(TYPE_COLLECTION, start)
|
|
266
|
+
|
|
267
|
+
const signals = new Map<string, Signal<T>>()
|
|
268
|
+
const keys: string[] = []
|
|
269
|
+
|
|
270
|
+
let keyCounter = 0
|
|
271
|
+
const keyConfig = options?.keyConfig
|
|
272
|
+
const generateKey: (item: T) => string =
|
|
273
|
+
typeof keyConfig === 'string'
|
|
274
|
+
? () => `${keyConfig}${keyCounter++}`
|
|
275
|
+
: isFunction<string>(keyConfig)
|
|
276
|
+
? (item: T) => keyConfig(item)
|
|
277
|
+
: () => String(keyCounter++)
|
|
278
|
+
|
|
279
|
+
const itemFactory =
|
|
280
|
+
options?.createItem ?? ((_key: string, value: T) => createState(value))
|
|
281
|
+
|
|
282
|
+
// Build current value from child signals
|
|
283
|
+
function buildValue(): T[] {
|
|
284
|
+
const result: T[] = []
|
|
285
|
+
for (const key of keys) {
|
|
286
|
+
try {
|
|
287
|
+
const v = signals.get(key)?.get()
|
|
288
|
+
if (v != null) result.push(v)
|
|
289
|
+
} catch (e) {
|
|
290
|
+
// Skip pending async items; rethrow real errors
|
|
291
|
+
if (!(e instanceof UnsetSignalValueError)) throw e
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return result
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const node: MemoNode<T[]> = {
|
|
298
|
+
fn: buildValue,
|
|
299
|
+
value: initialValue,
|
|
300
|
+
flags: FLAG_DIRTY,
|
|
301
|
+
sources: null,
|
|
302
|
+
sourcesTail: null,
|
|
303
|
+
sinks: null,
|
|
304
|
+
sinksTail: null,
|
|
305
|
+
equals: () => false, // Always rebuild — structural changes are managed externally
|
|
306
|
+
error: undefined,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Apply external changes to the collection */
|
|
310
|
+
function applyChanges(changes: DiffResult): void {
|
|
311
|
+
if (!changes.changed) return
|
|
312
|
+
let structural = false
|
|
313
|
+
|
|
314
|
+
batch(() => {
|
|
315
|
+
// Additions
|
|
316
|
+
for (const key in changes.add) {
|
|
317
|
+
const value = changes.add[key] as T
|
|
318
|
+
signals.set(key, itemFactory(key, value))
|
|
319
|
+
if (!keys.includes(key)) keys.push(key)
|
|
320
|
+
structural = true
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Changes — only for State signals
|
|
324
|
+
for (const key in changes.change) {
|
|
325
|
+
const signal = signals.get(key)
|
|
326
|
+
if (signal && isState(signal)) {
|
|
327
|
+
signal.set(changes.change[key] as T)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Removals
|
|
332
|
+
for (const key in changes.remove) {
|
|
333
|
+
signals.delete(key)
|
|
334
|
+
const index = keys.indexOf(key)
|
|
335
|
+
if (index !== -1) keys.splice(index, 1)
|
|
336
|
+
structural = true
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (structural) {
|
|
340
|
+
node.sources = null
|
|
341
|
+
node.sourcesTail = null
|
|
342
|
+
}
|
|
343
|
+
// Reset CHECK/RUNNING before propagate; mark DIRTY so next get() rebuilds
|
|
344
|
+
node.flags = FLAG_CLEAN
|
|
345
|
+
propagate(node as unknown as SinkNode)
|
|
346
|
+
node.flags |= FLAG_DIRTY
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Initialize signals for initial value
|
|
351
|
+
for (const item of initialValue) {
|
|
352
|
+
const key = generateKey(item)
|
|
353
|
+
signals.set(key, itemFactory(key, item))
|
|
354
|
+
keys.push(key)
|
|
355
|
+
}
|
|
356
|
+
node.value = initialValue
|
|
357
|
+
node.flags = FLAG_DIRTY // First refresh() will establish child edges
|
|
358
|
+
|
|
359
|
+
function startWatching(): void {
|
|
360
|
+
if (!node.sinks) node.stop = start(applyChanges)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const collection: Collection<T> = {
|
|
364
|
+
[Symbol.toStringTag]: TYPE_COLLECTION,
|
|
365
|
+
[Symbol.isConcatSpreadable]: true as const,
|
|
366
|
+
|
|
367
|
+
*[Symbol.iterator]() {
|
|
368
|
+
for (const key of keys) {
|
|
369
|
+
const signal = signals.get(key)
|
|
370
|
+
if (signal) yield signal
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
get length() {
|
|
375
|
+
if (activeSink) {
|
|
376
|
+
startWatching()
|
|
377
|
+
link(node, activeSink)
|
|
378
|
+
}
|
|
379
|
+
return keys.length
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
keys() {
|
|
383
|
+
if (activeSink) {
|
|
384
|
+
startWatching()
|
|
385
|
+
link(node, activeSink)
|
|
386
|
+
}
|
|
387
|
+
return keys.values()
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
get() {
|
|
391
|
+
if (activeSink) {
|
|
392
|
+
startWatching()
|
|
393
|
+
link(node, activeSink)
|
|
394
|
+
}
|
|
395
|
+
if (node.sources) {
|
|
396
|
+
if (node.flags) {
|
|
397
|
+
node.value = untrack(buildValue)
|
|
398
|
+
node.flags = FLAG_CLEAN
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
refresh(node as unknown as SinkNode)
|
|
402
|
+
if (node.error) throw node.error
|
|
403
|
+
}
|
|
404
|
+
return node.value
|
|
405
|
+
},
|
|
406
|
+
|
|
407
|
+
at(index: number) {
|
|
408
|
+
return signals.get(keys[index])
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
byKey(key: string) {
|
|
412
|
+
return signals.get(key)
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
keyAt(index: number) {
|
|
416
|
+
return keys[index]
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
indexOfKey(key: string) {
|
|
420
|
+
return keys.indexOf(key)
|
|
421
|
+
},
|
|
422
|
+
|
|
423
|
+
deriveCollection<R extends {}>(
|
|
424
|
+
cb: DeriveCollectionCallback<R, T>,
|
|
425
|
+
): Collection<R> {
|
|
426
|
+
return (
|
|
427
|
+
deriveCollection as <T2 extends {}, U2 extends {}>(
|
|
428
|
+
source: CollectionSource<U2>,
|
|
429
|
+
callback: DeriveCollectionCallback<T2, U2>,
|
|
430
|
+
) => Collection<T2>
|
|
431
|
+
)(collection, cb)
|
|
432
|
+
},
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return collection
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Checks if a value is a Collection signal.
|
|
440
|
+
*
|
|
441
|
+
* @since 0.17.2
|
|
442
|
+
* @param value - The value to check
|
|
443
|
+
* @returns True if the value is a Collection
|
|
444
|
+
*/
|
|
445
|
+
function isCollection<T extends {}>(value: unknown): value is Collection<T> {
|
|
446
|
+
return isObjectOfType(value, TYPE_COLLECTION)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Checks if a value is a valid Collection source (List or Collection).
|
|
451
|
+
*
|
|
452
|
+
* @since 0.17.2
|
|
453
|
+
* @param value - The value to check
|
|
454
|
+
* @returns True if the value is a List or Collection
|
|
455
|
+
*/
|
|
456
|
+
function isCollectionSource<T extends {}>(
|
|
457
|
+
value: unknown,
|
|
458
|
+
): value is CollectionSource<T> {
|
|
459
|
+
return isList(value) || isCollection(value)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/* === Exports === */
|
|
463
|
+
|
|
464
|
+
export {
|
|
465
|
+
createCollection,
|
|
466
|
+
deriveCollection,
|
|
467
|
+
isCollection,
|
|
468
|
+
isCollectionSource,
|
|
469
|
+
type Collection,
|
|
470
|
+
type CollectionCallback,
|
|
471
|
+
type CollectionOptions,
|
|
472
|
+
type CollectionSource,
|
|
473
|
+
type DeriveCollectionCallback,
|
|
474
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RequiredOwnerError,
|
|
3
|
+
UnsetSignalValueError,
|
|
4
|
+
validateCallback,
|
|
5
|
+
} from '../errors'
|
|
6
|
+
import {
|
|
7
|
+
activeOwner,
|
|
8
|
+
type Cleanup,
|
|
9
|
+
type EffectCallback,
|
|
10
|
+
type EffectNode,
|
|
11
|
+
FLAG_CLEAN,
|
|
12
|
+
FLAG_DIRTY,
|
|
13
|
+
type MaybeCleanup,
|
|
14
|
+
registerCleanup,
|
|
15
|
+
runCleanup,
|
|
16
|
+
runEffect,
|
|
17
|
+
type Signal,
|
|
18
|
+
trimSources,
|
|
19
|
+
} from '../graph'
|
|
20
|
+
|
|
21
|
+
/* === Types === */
|
|
22
|
+
|
|
23
|
+
type MaybePromise<T> = T | Promise<T>
|
|
24
|
+
|
|
25
|
+
type MatchHandlers<T extends Signal<unknown & {}>[]> = {
|
|
26
|
+
ok: (values: {
|
|
27
|
+
[K in keyof T]: T[K] extends Signal<infer V> ? V : never
|
|
28
|
+
}) => MaybePromise<MaybeCleanup>
|
|
29
|
+
err?: (errors: readonly Error[]) => MaybePromise<MaybeCleanup>
|
|
30
|
+
nil?: () => MaybePromise<MaybeCleanup>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* === Exported Functions === */
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Creates a reactive effect that automatically runs when its dependencies change.
|
|
37
|
+
* Effects run immediately upon creation and re-run when any tracked signal changes.
|
|
38
|
+
* Effects are executed during the flush phase, after all updates have been batched.
|
|
39
|
+
*
|
|
40
|
+
* @since 0.1.0
|
|
41
|
+
* @param fn - The effect function that can track dependencies and register cleanup callbacks
|
|
42
|
+
* @returns A cleanup function that can be called to dispose of the effect
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* const count = createState(0);
|
|
47
|
+
* const dispose = createEffect(() => {
|
|
48
|
+
* console.log('Count is:', count.get());
|
|
49
|
+
* });
|
|
50
|
+
*
|
|
51
|
+
* count.set(1); // Logs: "Count is: 1"
|
|
52
|
+
* dispose(); // Stop the effect
|
|
53
|
+
* ```
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```ts
|
|
57
|
+
* // With cleanup
|
|
58
|
+
* createEffect(() => {
|
|
59
|
+
* const timer = setInterval(() => console.log(count.get()), 1000);
|
|
60
|
+
* return () => clearInterval(timer);
|
|
61
|
+
* });
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
function createEffect(fn: EffectCallback): Cleanup {
|
|
65
|
+
validateCallback('Effect', fn)
|
|
66
|
+
|
|
67
|
+
const node: EffectNode = {
|
|
68
|
+
fn,
|
|
69
|
+
flags: FLAG_DIRTY,
|
|
70
|
+
sources: null,
|
|
71
|
+
sourcesTail: null,
|
|
72
|
+
cleanup: null,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const dispose = () => {
|
|
76
|
+
runCleanup(node)
|
|
77
|
+
node.fn = undefined as unknown as EffectCallback
|
|
78
|
+
node.flags = FLAG_CLEAN
|
|
79
|
+
node.sourcesTail = null
|
|
80
|
+
trimSources(node)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (activeOwner) registerCleanup(activeOwner, dispose)
|
|
84
|
+
|
|
85
|
+
runEffect(node)
|
|
86
|
+
|
|
87
|
+
return dispose
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Runs handlers based on the current values of signals.
|
|
92
|
+
* Must be called within an active owner (effect or scope) so async cleanup can be registered.
|
|
93
|
+
*
|
|
94
|
+
* @since 0.15.0
|
|
95
|
+
* @throws RequiredOwnerError If called without an active owner.
|
|
96
|
+
*/
|
|
97
|
+
function match<T extends Signal<unknown & {}>[]>(
|
|
98
|
+
signals: T,
|
|
99
|
+
handlers: MatchHandlers<T>,
|
|
100
|
+
): MaybeCleanup {
|
|
101
|
+
if (!activeOwner) throw new RequiredOwnerError('match')
|
|
102
|
+
const { ok, err = console.error, nil } = handlers
|
|
103
|
+
let errors: Error[] | undefined
|
|
104
|
+
let pending = false
|
|
105
|
+
const values = new Array(signals.length)
|
|
106
|
+
|
|
107
|
+
for (let i = 0; i < signals.length; i++) {
|
|
108
|
+
try {
|
|
109
|
+
values[i] = signals[i].get()
|
|
110
|
+
} catch (e) {
|
|
111
|
+
if (e instanceof UnsetSignalValueError) {
|
|
112
|
+
pending = true
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
if (!errors) errors = []
|
|
116
|
+
errors.push(e instanceof Error ? e : new Error(String(e)))
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let out: MaybePromise<MaybeCleanup>
|
|
121
|
+
try {
|
|
122
|
+
if (pending) out = nil?.()
|
|
123
|
+
else if (errors) out = err(errors)
|
|
124
|
+
else
|
|
125
|
+
out = ok(
|
|
126
|
+
values as {
|
|
127
|
+
[K in keyof T]: T[K] extends Signal<infer V> ? V : never
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
} catch (e) {
|
|
131
|
+
err([e instanceof Error ? e : new Error(String(e))])
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (typeof out === 'function') return out
|
|
135
|
+
|
|
136
|
+
if (out instanceof Promise) {
|
|
137
|
+
const owner = activeOwner
|
|
138
|
+
const controller = new AbortController()
|
|
139
|
+
registerCleanup(owner, () => controller.abort())
|
|
140
|
+
out.then(cleanup => {
|
|
141
|
+
if (!controller.signal.aborted && typeof cleanup === 'function')
|
|
142
|
+
registerCleanup(owner, cleanup)
|
|
143
|
+
}).catch(e => {
|
|
144
|
+
err([e instanceof Error ? e : new Error(String(e))])
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export { type MaybePromise, type MatchHandlers, createEffect, match }
|