@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,588 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CircularDependencyError,
|
|
3
|
+
DuplicateKeyError,
|
|
4
|
+
validateSignalValue,
|
|
5
|
+
} from '../errors'
|
|
6
|
+
import {
|
|
7
|
+
activeSink,
|
|
8
|
+
batch,
|
|
9
|
+
batchDepth,
|
|
10
|
+
type Cleanup,
|
|
11
|
+
FLAG_CLEAN,
|
|
12
|
+
FLAG_DIRTY,
|
|
13
|
+
flush,
|
|
14
|
+
link,
|
|
15
|
+
type MemoNode,
|
|
16
|
+
propagate,
|
|
17
|
+
refresh,
|
|
18
|
+
type SinkNode,
|
|
19
|
+
TYPE_LIST,
|
|
20
|
+
untrack,
|
|
21
|
+
} from '../graph'
|
|
22
|
+
import { isFunction, isObjectOfType, isRecord } from '../util'
|
|
23
|
+
import {
|
|
24
|
+
type Collection,
|
|
25
|
+
type CollectionSource,
|
|
26
|
+
type DeriveCollectionCallback,
|
|
27
|
+
deriveCollection,
|
|
28
|
+
} from './collection'
|
|
29
|
+
import { createState, type State } from './state'
|
|
30
|
+
|
|
31
|
+
/* === Types === */
|
|
32
|
+
|
|
33
|
+
type UnknownRecord = Record<string, unknown>
|
|
34
|
+
|
|
35
|
+
type DiffResult = {
|
|
36
|
+
changed: boolean
|
|
37
|
+
add: UnknownRecord
|
|
38
|
+
change: UnknownRecord
|
|
39
|
+
remove: UnknownRecord
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type KeyConfig<T> = string | ((item: T) => string)
|
|
43
|
+
|
|
44
|
+
type ListOptions<T extends {}> = {
|
|
45
|
+
keyConfig?: KeyConfig<T>
|
|
46
|
+
watched?: () => Cleanup
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type List<T extends {}> = {
|
|
50
|
+
readonly [Symbol.toStringTag]: 'List'
|
|
51
|
+
readonly [Symbol.isConcatSpreadable]: true
|
|
52
|
+
[Symbol.iterator](): IterableIterator<State<T>>
|
|
53
|
+
readonly length: number
|
|
54
|
+
get(): T[]
|
|
55
|
+
set(newValue: T[]): void
|
|
56
|
+
update(fn: (oldValue: T[]) => T[]): void
|
|
57
|
+
at(index: number): State<T> | undefined
|
|
58
|
+
keys(): IterableIterator<string>
|
|
59
|
+
byKey(key: string): State<T> | undefined
|
|
60
|
+
keyAt(index: number): string | undefined
|
|
61
|
+
indexOfKey(key: string): number
|
|
62
|
+
add(value: T): string
|
|
63
|
+
remove(keyOrIndex: string | number): void
|
|
64
|
+
sort(compareFn?: (a: T, b: T) => number): void
|
|
65
|
+
splice(start: number, deleteCount?: number, ...items: T[]): T[]
|
|
66
|
+
deriveCollection<R extends {}>(
|
|
67
|
+
callback: (sourceValue: T) => R,
|
|
68
|
+
): Collection<R>
|
|
69
|
+
deriveCollection<R extends {}>(
|
|
70
|
+
callback: (sourceValue: T, abort: AbortSignal) => Promise<R>,
|
|
71
|
+
): Collection<R>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* === Functions === */
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Checks if two values are equal with cycle detection
|
|
78
|
+
*
|
|
79
|
+
* @since 0.15.0
|
|
80
|
+
* @param {T} a - First value to compare
|
|
81
|
+
* @param {T} b - Second value to compare
|
|
82
|
+
* @param {WeakSet<object>} visited - Set to track visited objects for cycle detection
|
|
83
|
+
* @returns {boolean} Whether the two values are equal
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/** Shallow equality check for string arrays */
|
|
87
|
+
function keysEqual(a: string[], b: string[]): boolean {
|
|
88
|
+
if (a.length !== b.length) return false
|
|
89
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isEqual<T>(a: T, b: T, visited?: WeakSet<object>): boolean {
|
|
94
|
+
// Fast paths
|
|
95
|
+
if (Object.is(a, b)) return true
|
|
96
|
+
if (typeof a !== typeof b) return false
|
|
97
|
+
if (
|
|
98
|
+
a == null ||
|
|
99
|
+
typeof a !== 'object' ||
|
|
100
|
+
b == null ||
|
|
101
|
+
typeof b !== 'object'
|
|
102
|
+
)
|
|
103
|
+
return false
|
|
104
|
+
|
|
105
|
+
// Cycle detection (only allocate WeakSet when both values are objects)
|
|
106
|
+
if (!visited) visited = new WeakSet()
|
|
107
|
+
if (visited.has(a as object) || visited.has(b as object))
|
|
108
|
+
throw new CircularDependencyError('isEqual')
|
|
109
|
+
visited.add(a)
|
|
110
|
+
visited.add(b)
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const aIsArray = Array.isArray(a)
|
|
114
|
+
if (aIsArray !== Array.isArray(b)) return false
|
|
115
|
+
|
|
116
|
+
if (aIsArray) {
|
|
117
|
+
const aa = a as unknown[]
|
|
118
|
+
const ba = b as unknown[]
|
|
119
|
+
if (aa.length !== ba.length) return false
|
|
120
|
+
for (let i = 0; i < aa.length; i++) {
|
|
121
|
+
if (!isEqual(aa[i], ba[i], visited)) return false
|
|
122
|
+
}
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (isRecord(a) && isRecord(b)) {
|
|
127
|
+
const aKeys = Object.keys(a)
|
|
128
|
+
const bKeys = Object.keys(b)
|
|
129
|
+
|
|
130
|
+
if (aKeys.length !== bKeys.length) return false
|
|
131
|
+
for (const key of aKeys) {
|
|
132
|
+
if (!(key in b)) return false
|
|
133
|
+
if (!isEqual(a[key], b[key], visited)) return false
|
|
134
|
+
}
|
|
135
|
+
return true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// For non-records/non-arrays, they are only equal if they are the same reference
|
|
139
|
+
// (which would have been caught by Object.is at the beginning)
|
|
140
|
+
return false
|
|
141
|
+
} finally {
|
|
142
|
+
visited.delete(a)
|
|
143
|
+
visited.delete(b)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Compares two arrays using existing keys and returns differences as a DiffResult.
|
|
149
|
+
* Avoids object conversion by working directly with arrays and keys.
|
|
150
|
+
*
|
|
151
|
+
* @since 0.18.0
|
|
152
|
+
* @param {T[]} oldArray - The old array
|
|
153
|
+
* @param {T[]} newArray - The new array
|
|
154
|
+
* @param {string[]} currentKeys - Current keys array (may be sparse or shorter than oldArray)
|
|
155
|
+
* @param {(item: T) => string} generateKey - Function to generate keys for new items
|
|
156
|
+
* @param {boolean} contentBased - When true, always use generateKey (content-based keys);
|
|
157
|
+
* when false, reuse positional keys from currentKeys (synthetic keys)
|
|
158
|
+
* @returns {DiffResult & { newKeys: string[] }} The differences in DiffResult format plus updated keys array
|
|
159
|
+
*/
|
|
160
|
+
function diffArrays<T>(
|
|
161
|
+
oldArray: T[],
|
|
162
|
+
newArray: T[],
|
|
163
|
+
currentKeys: string[],
|
|
164
|
+
generateKey: (item: T) => string,
|
|
165
|
+
contentBased: boolean,
|
|
166
|
+
): DiffResult & { newKeys: string[] } {
|
|
167
|
+
const visited = new WeakSet()
|
|
168
|
+
const add = {} as UnknownRecord
|
|
169
|
+
const change = {} as UnknownRecord
|
|
170
|
+
const remove = {} as UnknownRecord
|
|
171
|
+
const newKeys: string[] = []
|
|
172
|
+
let changed = false
|
|
173
|
+
|
|
174
|
+
// Build a map of old values by key for quick lookup
|
|
175
|
+
const oldByKey = new Map<string, T>()
|
|
176
|
+
for (let i = 0; i < oldArray.length; i++) {
|
|
177
|
+
const key = currentKeys[i]
|
|
178
|
+
if (key && oldArray[i]) oldByKey.set(key, oldArray[i])
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Track which old keys we've seen
|
|
182
|
+
const seenKeys = new Set<string>()
|
|
183
|
+
|
|
184
|
+
// Process new array and build new keys array
|
|
185
|
+
for (let i = 0; i < newArray.length; i++) {
|
|
186
|
+
const newValue = newArray[i]
|
|
187
|
+
if (newValue === undefined) continue
|
|
188
|
+
|
|
189
|
+
// Content-based keys: always derive from item; synthetic keys: reuse by position
|
|
190
|
+
const key = contentBased
|
|
191
|
+
? generateKey(newValue)
|
|
192
|
+
: (currentKeys[i] ?? generateKey(newValue))
|
|
193
|
+
|
|
194
|
+
if (seenKeys.has(key))
|
|
195
|
+
throw new DuplicateKeyError(TYPE_LIST, key, newValue)
|
|
196
|
+
|
|
197
|
+
newKeys.push(key)
|
|
198
|
+
seenKeys.add(key)
|
|
199
|
+
|
|
200
|
+
// Check if this key existed before
|
|
201
|
+
if (!oldByKey.has(key)) {
|
|
202
|
+
add[key] = newValue
|
|
203
|
+
changed = true
|
|
204
|
+
} else {
|
|
205
|
+
const oldValue = oldByKey.get(key)
|
|
206
|
+
if (!isEqual(oldValue, newValue, visited)) {
|
|
207
|
+
change[key] = newValue
|
|
208
|
+
changed = true
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Find removed keys (existed in old but not in new)
|
|
214
|
+
for (const [key] of oldByKey) {
|
|
215
|
+
if (!seenKeys.has(key)) {
|
|
216
|
+
remove[key] = null
|
|
217
|
+
changed = true
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Detect reorder even when no values changed
|
|
222
|
+
if (!changed && !keysEqual(currentKeys, newKeys)) changed = true
|
|
223
|
+
|
|
224
|
+
return { add, change, remove, newKeys, changed }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Creates a reactive list with stable keys and per-item reactivity.
|
|
229
|
+
*
|
|
230
|
+
* @since 0.18.0
|
|
231
|
+
* @param initialValue - Initial array of items
|
|
232
|
+
* @param options - Optional configuration for key generation and watch lifecycle
|
|
233
|
+
* @returns A List signal
|
|
234
|
+
*/
|
|
235
|
+
function createList<T extends {}>(
|
|
236
|
+
initialValue: T[],
|
|
237
|
+
options?: ListOptions<T>,
|
|
238
|
+
): List<T> {
|
|
239
|
+
validateSignalValue(TYPE_LIST, initialValue, Array.isArray)
|
|
240
|
+
|
|
241
|
+
const signals = new Map<string, State<T>>()
|
|
242
|
+
let keys: string[] = []
|
|
243
|
+
|
|
244
|
+
let keyCounter = 0
|
|
245
|
+
const keyConfig = options?.keyConfig
|
|
246
|
+
const contentBased = isFunction<string>(keyConfig)
|
|
247
|
+
const generateKey: (item: T) => string =
|
|
248
|
+
typeof keyConfig === 'string'
|
|
249
|
+
? () => `${keyConfig}${keyCounter++}`
|
|
250
|
+
: contentBased
|
|
251
|
+
? (item: T) => keyConfig(item)
|
|
252
|
+
: () => String(keyCounter++)
|
|
253
|
+
|
|
254
|
+
// --- Internal helpers ---
|
|
255
|
+
|
|
256
|
+
// Build current value from child signals
|
|
257
|
+
const buildValue = (): T[] =>
|
|
258
|
+
keys
|
|
259
|
+
.map(key => signals.get(key)?.get())
|
|
260
|
+
.filter(v => v !== undefined) as T[]
|
|
261
|
+
|
|
262
|
+
// Structural tracking node — not a general-purpose Memo.
|
|
263
|
+
// On first get(): refresh() establishes edges from child signals.
|
|
264
|
+
// On subsequent get(): untrack(buildValue) rebuilds without re-linking.
|
|
265
|
+
// Mutation methods (add/remove/set/splice) null out sources to force re-establishment.
|
|
266
|
+
const node: MemoNode<T[]> = {
|
|
267
|
+
fn: buildValue,
|
|
268
|
+
value: initialValue,
|
|
269
|
+
flags: FLAG_DIRTY,
|
|
270
|
+
sources: null,
|
|
271
|
+
sourcesTail: null,
|
|
272
|
+
sinks: null,
|
|
273
|
+
sinksTail: null,
|
|
274
|
+
equals: isEqual,
|
|
275
|
+
error: undefined,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const toRecord = (array: T[]): Record<string, T> => {
|
|
279
|
+
const record = {} as Record<string, T>
|
|
280
|
+
for (let i = 0; i < array.length; i++) {
|
|
281
|
+
const value = array[i]
|
|
282
|
+
if (value === undefined) continue
|
|
283
|
+
let key = keys[i]
|
|
284
|
+
if (!key) {
|
|
285
|
+
key = generateKey(value)
|
|
286
|
+
keys[i] = key
|
|
287
|
+
}
|
|
288
|
+
record[key] = value
|
|
289
|
+
}
|
|
290
|
+
return record
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const applyChanges = (changes: DiffResult): boolean => {
|
|
294
|
+
let structural = false
|
|
295
|
+
|
|
296
|
+
// Additions
|
|
297
|
+
for (const key in changes.add) {
|
|
298
|
+
const value = changes.add[key] as T
|
|
299
|
+
validateSignalValue(`${TYPE_LIST} item for key "${key}"`, value)
|
|
300
|
+
signals.set(key, createState(value))
|
|
301
|
+
structural = true
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Changes
|
|
305
|
+
if (Object.keys(changes.change).length) {
|
|
306
|
+
batch(() => {
|
|
307
|
+
for (const key in changes.change) {
|
|
308
|
+
const value = changes.change[key]
|
|
309
|
+
validateSignalValue(
|
|
310
|
+
`${TYPE_LIST} item for key "${key}"`,
|
|
311
|
+
value,
|
|
312
|
+
)
|
|
313
|
+
const signal = signals.get(key)
|
|
314
|
+
if (signal) signal.set(value as T)
|
|
315
|
+
}
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Removals
|
|
320
|
+
for (const key in changes.remove) {
|
|
321
|
+
signals.delete(key)
|
|
322
|
+
const index = keys.indexOf(key)
|
|
323
|
+
if (index !== -1) keys.splice(index, 1)
|
|
324
|
+
structural = true
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (structural) {
|
|
328
|
+
node.sources = null
|
|
329
|
+
node.sourcesTail = null
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return changes.changed
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- Initialize ---
|
|
336
|
+
const initRecord = toRecord(initialValue)
|
|
337
|
+
for (const key in initRecord) {
|
|
338
|
+
const value = initRecord[key]
|
|
339
|
+
validateSignalValue(`${TYPE_LIST} item for key "${key}"`, value)
|
|
340
|
+
signals.set(key, createState(value))
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Starts clean: mutation methods (add/remove/set/splice) explicitly call
|
|
344
|
+
// propagate() + invalidate edges, so refresh() on first get() is not needed.
|
|
345
|
+
node.value = initialValue
|
|
346
|
+
node.flags = 0
|
|
347
|
+
|
|
348
|
+
// --- List object ---
|
|
349
|
+
const list: List<T> = {
|
|
350
|
+
[Symbol.toStringTag]: TYPE_LIST,
|
|
351
|
+
[Symbol.isConcatSpreadable]: true as const,
|
|
352
|
+
|
|
353
|
+
*[Symbol.iterator]() {
|
|
354
|
+
for (const key of keys) {
|
|
355
|
+
const signal = signals.get(key)
|
|
356
|
+
if (signal) yield signal
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
get length() {
|
|
361
|
+
if (activeSink) {
|
|
362
|
+
if (!node.sinks && options?.watched)
|
|
363
|
+
node.stop = options.watched()
|
|
364
|
+
link(node, activeSink)
|
|
365
|
+
}
|
|
366
|
+
return keys.length
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
get() {
|
|
370
|
+
if (activeSink) {
|
|
371
|
+
if (!node.sinks && options?.watched)
|
|
372
|
+
node.stop = options.watched()
|
|
373
|
+
link(node, activeSink)
|
|
374
|
+
}
|
|
375
|
+
if (node.sources) {
|
|
376
|
+
// Fast path: edges already established, rebuild value directly
|
|
377
|
+
if (node.flags) {
|
|
378
|
+
node.value = untrack(buildValue)
|
|
379
|
+
node.flags = FLAG_CLEAN
|
|
380
|
+
}
|
|
381
|
+
} else {
|
|
382
|
+
// First access: use refresh() to establish child → list edges
|
|
383
|
+
refresh(node as unknown as SinkNode)
|
|
384
|
+
if (node.error) throw node.error
|
|
385
|
+
}
|
|
386
|
+
return node.value
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
set(newValue: T[]) {
|
|
390
|
+
const currentValue =
|
|
391
|
+
node.flags & FLAG_DIRTY ? buildValue() : node.value
|
|
392
|
+
const changes = diffArrays(
|
|
393
|
+
currentValue,
|
|
394
|
+
newValue,
|
|
395
|
+
keys,
|
|
396
|
+
generateKey,
|
|
397
|
+
contentBased,
|
|
398
|
+
)
|
|
399
|
+
if (changes.changed) {
|
|
400
|
+
keys = changes.newKeys
|
|
401
|
+
applyChanges(changes)
|
|
402
|
+
propagate(node as unknown as SinkNode)
|
|
403
|
+
node.flags |= FLAG_DIRTY
|
|
404
|
+
if (batchDepth === 0) flush()
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
update(fn: (oldValue: T[]) => T[]) {
|
|
409
|
+
list.set(fn(list.get()))
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
at(index: number) {
|
|
413
|
+
return signals.get(keys[index])
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
keys() {
|
|
417
|
+
if (activeSink) {
|
|
418
|
+
if (!node.sinks && options?.watched)
|
|
419
|
+
node.stop = options.watched()
|
|
420
|
+
link(node, activeSink)
|
|
421
|
+
}
|
|
422
|
+
return keys.values()
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
byKey(key: string) {
|
|
426
|
+
return signals.get(key)
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
keyAt(index: number) {
|
|
430
|
+
return keys[index]
|
|
431
|
+
},
|
|
432
|
+
|
|
433
|
+
indexOfKey(key: string) {
|
|
434
|
+
return keys.indexOf(key)
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
add(value: T) {
|
|
438
|
+
const key = generateKey(value)
|
|
439
|
+
if (signals.has(key))
|
|
440
|
+
throw new DuplicateKeyError(TYPE_LIST, key, value)
|
|
441
|
+
if (!keys.includes(key)) keys.push(key)
|
|
442
|
+
validateSignalValue(`${TYPE_LIST} item for key "${key}"`, value)
|
|
443
|
+
signals.set(key, createState(value))
|
|
444
|
+
node.sources = null
|
|
445
|
+
node.sourcesTail = null
|
|
446
|
+
propagate(node as unknown as SinkNode)
|
|
447
|
+
node.flags |= FLAG_DIRTY
|
|
448
|
+
if (batchDepth === 0) flush()
|
|
449
|
+
return key
|
|
450
|
+
},
|
|
451
|
+
|
|
452
|
+
remove(keyOrIndex: string | number) {
|
|
453
|
+
const key =
|
|
454
|
+
typeof keyOrIndex === 'number' ? keys[keyOrIndex] : keyOrIndex
|
|
455
|
+
const ok = signals.delete(key)
|
|
456
|
+
if (ok) {
|
|
457
|
+
const index =
|
|
458
|
+
typeof keyOrIndex === 'number'
|
|
459
|
+
? keyOrIndex
|
|
460
|
+
: keys.indexOf(key)
|
|
461
|
+
if (index >= 0) keys.splice(index, 1)
|
|
462
|
+
node.sources = null
|
|
463
|
+
node.sourcesTail = null
|
|
464
|
+
propagate(node as unknown as SinkNode)
|
|
465
|
+
node.flags |= FLAG_DIRTY
|
|
466
|
+
if (batchDepth === 0) flush()
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
|
|
470
|
+
sort(compareFn?: (a: T, b: T) => number) {
|
|
471
|
+
const entries = keys
|
|
472
|
+
.map(key => [key, signals.get(key)?.get()] as [string, T])
|
|
473
|
+
.sort(
|
|
474
|
+
isFunction(compareFn)
|
|
475
|
+
? (a, b) => compareFn(a[1], b[1])
|
|
476
|
+
: (a, b) => String(a[1]).localeCompare(String(b[1])),
|
|
477
|
+
)
|
|
478
|
+
const newOrder = entries.map(([key]) => key)
|
|
479
|
+
|
|
480
|
+
if (!keysEqual(keys, newOrder)) {
|
|
481
|
+
keys = newOrder
|
|
482
|
+
propagate(node as unknown as SinkNode)
|
|
483
|
+
node.flags |= FLAG_DIRTY
|
|
484
|
+
if (batchDepth === 0) flush()
|
|
485
|
+
}
|
|
486
|
+
},
|
|
487
|
+
|
|
488
|
+
splice(start: number, deleteCount?: number, ...items: T[]) {
|
|
489
|
+
const length = keys.length
|
|
490
|
+
const actualStart =
|
|
491
|
+
start < 0
|
|
492
|
+
? Math.max(0, length + start)
|
|
493
|
+
: Math.min(start, length)
|
|
494
|
+
const actualDeleteCount = Math.max(
|
|
495
|
+
0,
|
|
496
|
+
Math.min(
|
|
497
|
+
deleteCount ??
|
|
498
|
+
Math.max(0, length - Math.max(0, actualStart)),
|
|
499
|
+
length - actualStart,
|
|
500
|
+
),
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
const add = {} as Record<string, T>
|
|
504
|
+
const remove = {} as Record<string, T>
|
|
505
|
+
|
|
506
|
+
// Collect items to delete
|
|
507
|
+
for (let i = 0; i < actualDeleteCount; i++) {
|
|
508
|
+
const index = actualStart + i
|
|
509
|
+
const key = keys[index]
|
|
510
|
+
if (key) {
|
|
511
|
+
const signal = signals.get(key)
|
|
512
|
+
if (signal) remove[key] = signal.get() as T
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Build new key order
|
|
517
|
+
const newOrder = keys.slice(0, actualStart)
|
|
518
|
+
|
|
519
|
+
for (const item of items) {
|
|
520
|
+
const key = generateKey(item)
|
|
521
|
+
if (signals.has(key) && !(key in remove))
|
|
522
|
+
throw new DuplicateKeyError(TYPE_LIST, key, item)
|
|
523
|
+
newOrder.push(key)
|
|
524
|
+
add[key] = item
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
newOrder.push(...keys.slice(actualStart + actualDeleteCount))
|
|
528
|
+
|
|
529
|
+
const changed = !!(
|
|
530
|
+
Object.keys(add).length || Object.keys(remove).length
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
if (changed) {
|
|
534
|
+
applyChanges({
|
|
535
|
+
add,
|
|
536
|
+
change: {},
|
|
537
|
+
remove,
|
|
538
|
+
changed,
|
|
539
|
+
})
|
|
540
|
+
keys = newOrder
|
|
541
|
+
propagate(node as unknown as SinkNode)
|
|
542
|
+
node.flags |= FLAG_DIRTY
|
|
543
|
+
if (batchDepth === 0) flush()
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return Object.values(remove)
|
|
547
|
+
},
|
|
548
|
+
|
|
549
|
+
deriveCollection<R extends {}>(
|
|
550
|
+
cb: DeriveCollectionCallback<R, T>,
|
|
551
|
+
): Collection<R> {
|
|
552
|
+
return (
|
|
553
|
+
deriveCollection as <T2 extends {}, U2 extends {}>(
|
|
554
|
+
source: CollectionSource<U2>,
|
|
555
|
+
callback: DeriveCollectionCallback<T2, U2>,
|
|
556
|
+
) => Collection<T2>
|
|
557
|
+
)(list, cb)
|
|
558
|
+
},
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return list
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Checks if a value is a List signal.
|
|
566
|
+
*
|
|
567
|
+
* @since 0.15.0
|
|
568
|
+
* @param value - The value to check
|
|
569
|
+
* @returns True if the value is a List
|
|
570
|
+
*/
|
|
571
|
+
function isList<T extends {}>(value: unknown): value is List<T> {
|
|
572
|
+
return isObjectOfType(value, TYPE_LIST)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/* === Exports === */
|
|
576
|
+
|
|
577
|
+
export {
|
|
578
|
+
type DiffResult,
|
|
579
|
+
type KeyConfig,
|
|
580
|
+
type List,
|
|
581
|
+
type ListOptions,
|
|
582
|
+
type UnknownRecord,
|
|
583
|
+
createList,
|
|
584
|
+
isEqual,
|
|
585
|
+
isList,
|
|
586
|
+
keysEqual,
|
|
587
|
+
TYPE_LIST,
|
|
588
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import {
|
|
2
|
+
validateCallback,
|
|
3
|
+
validateReadValue,
|
|
4
|
+
validateSignalValue,
|
|
5
|
+
} from '../errors'
|
|
6
|
+
import {
|
|
7
|
+
activeSink,
|
|
8
|
+
type ComputedOptions,
|
|
9
|
+
defaultEquals,
|
|
10
|
+
FLAG_DIRTY,
|
|
11
|
+
link,
|
|
12
|
+
type MemoCallback,
|
|
13
|
+
type MemoNode,
|
|
14
|
+
refresh,
|
|
15
|
+
type SinkNode,
|
|
16
|
+
TYPE_MEMO,
|
|
17
|
+
} from '../graph'
|
|
18
|
+
import { isObjectOfType, isSyncFunction } from '../util'
|
|
19
|
+
|
|
20
|
+
/* === Types === */
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A derived reactive computation that caches its result.
|
|
24
|
+
* Automatically tracks dependencies and recomputes when they change.
|
|
25
|
+
*
|
|
26
|
+
* @template T - The type of value computed by the memo
|
|
27
|
+
*/
|
|
28
|
+
type Memo<T extends {}> = {
|
|
29
|
+
readonly [Symbol.toStringTag]: 'Memo'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Gets the current value of the memo.
|
|
33
|
+
* Recomputes if dependencies have changed since last access.
|
|
34
|
+
* When called inside another reactive context, creates a dependency.
|
|
35
|
+
* @returns The computed value
|
|
36
|
+
* @throws UnsetSignalValueError If the memo value is still unset when read.
|
|
37
|
+
*/
|
|
38
|
+
get(): T
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* === Exported Functions === */
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Creates a derived reactive computation that caches its result.
|
|
45
|
+
* The computation automatically tracks dependencies and recomputes when they change.
|
|
46
|
+
* Uses lazy evaluation - only computes when the value is accessed.
|
|
47
|
+
*
|
|
48
|
+
* @since 0.18.0
|
|
49
|
+
* @template T - The type of value computed by the memo
|
|
50
|
+
* @param fn - The computation function that receives the previous value
|
|
51
|
+
* @param options - Optional configuration for the memo
|
|
52
|
+
* @returns A Memo object with a get() method
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```ts
|
|
56
|
+
* const count = createState(0);
|
|
57
|
+
* const doubled = createMemo(() => count.get() * 2);
|
|
58
|
+
* console.log(doubled.get()); // 0
|
|
59
|
+
* count.set(5);
|
|
60
|
+
* console.log(doubled.get()); // 10
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```ts
|
|
65
|
+
* // Using previous value
|
|
66
|
+
* const sum = createMemo((prev) => prev + count.get(), { value: 0, equals: Object.is });
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
function createMemo<T extends {}>(
|
|
70
|
+
fn: (prev: T) => T,
|
|
71
|
+
options: ComputedOptions<T> & { value: T },
|
|
72
|
+
): Memo<T>
|
|
73
|
+
function createMemo<T extends {}>(
|
|
74
|
+
fn: MemoCallback<T>,
|
|
75
|
+
options?: ComputedOptions<T>,
|
|
76
|
+
): Memo<T>
|
|
77
|
+
function createMemo<T extends {}>(
|
|
78
|
+
fn: MemoCallback<T>,
|
|
79
|
+
options?: ComputedOptions<T>,
|
|
80
|
+
): Memo<T> {
|
|
81
|
+
validateCallback(TYPE_MEMO, fn, isSyncFunction)
|
|
82
|
+
if (options?.value !== undefined)
|
|
83
|
+
validateSignalValue(TYPE_MEMO, options.value, options?.guard)
|
|
84
|
+
|
|
85
|
+
const node: MemoNode<T> = {
|
|
86
|
+
fn,
|
|
87
|
+
value: options?.value as T,
|
|
88
|
+
flags: FLAG_DIRTY,
|
|
89
|
+
sources: null,
|
|
90
|
+
sourcesTail: null,
|
|
91
|
+
sinks: null,
|
|
92
|
+
sinksTail: null,
|
|
93
|
+
equals: options?.equals ?? defaultEquals,
|
|
94
|
+
error: undefined,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
[Symbol.toStringTag]: TYPE_MEMO,
|
|
99
|
+
get() {
|
|
100
|
+
if (activeSink) link(node, activeSink)
|
|
101
|
+
refresh(node as unknown as SinkNode)
|
|
102
|
+
if (node.error) throw node.error
|
|
103
|
+
validateReadValue(TYPE_MEMO, node.value)
|
|
104
|
+
return node.value
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Checks if a value is a Memo signal.
|
|
111
|
+
*
|
|
112
|
+
* @since 0.18.0
|
|
113
|
+
* @param value - The value to check
|
|
114
|
+
* @returns True if the value is a Memo
|
|
115
|
+
*/
|
|
116
|
+
function isMemo<T extends {} = unknown & {}>(value: unknown): value is Memo<T> {
|
|
117
|
+
return isObjectOfType(value, TYPE_MEMO)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export { createMemo, isMemo, type Memo }
|