@zeix/cause-effect 0.18.0 → 0.18.2

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.
@@ -9,18 +9,20 @@ import {
9
9
  type Cleanup,
10
10
  FLAG_CLEAN,
11
11
  FLAG_DIRTY,
12
+ FLAG_RELINK,
12
13
  link,
13
14
  type MemoNode,
14
15
  propagate,
15
16
  refresh,
16
17
  type Signal,
17
18
  type SinkNode,
19
+ SKIP_EQUALITY,
18
20
  TYPE_COLLECTION,
19
21
  untrack,
20
22
  } from '../graph'
21
- import { isAsyncFunction, isFunction, isObjectOfType } from '../util'
23
+ import { isAsyncFunction, isObjectOfType, isSyncFunction } from '../util'
22
24
  import {
23
- type DiffResult,
25
+ getKeyGenerator,
24
26
  isList,
25
27
  type KeyConfig,
26
28
  keysEqual,
@@ -57,14 +59,20 @@ type Collection<T extends {}> = {
57
59
  readonly length: number
58
60
  }
59
61
 
62
+ type CollectionChanges<T> = {
63
+ add?: T[]
64
+ change?: T[]
65
+ remove?: T[]
66
+ }
67
+
60
68
  type CollectionOptions<T extends {}> = {
61
69
  value?: T[]
62
70
  keyConfig?: KeyConfig<T>
63
- createItem?: (key: string, value: T) => Signal<T>
71
+ createItem?: (value: T) => Signal<T>
64
72
  }
65
73
 
66
- type CollectionCallback = (
67
- applyChanges: (changes: DiffResult) => void,
74
+ type CollectionCallback<T extends {}> = (
75
+ apply: (changes: CollectionChanges<T>) => void,
68
76
  ) => Cleanup
69
77
 
70
78
  /* === Functions === */
@@ -99,6 +107,7 @@ function deriveCollection<T extends {}, U extends {}>(
99
107
 
100
108
  const isAsync = isAsyncFunction(callback)
101
109
  const signals = new Map<string, Memo<T>>()
110
+ let keys: string[] = []
102
111
 
103
112
  const addSignal = (key: string): void => {
104
113
  const signal = isAsync
@@ -121,68 +130,107 @@ function deriveCollection<T extends {}, U extends {}>(
121
130
  signals.set(key, signal as Memo<T>)
122
131
  }
123
132
 
124
- // Sync collection signals with source keys, reading source.keys()
133
+ // Sync signals map with the given keys.
134
+ // Intentionally side-effectful: mutates the private signals map and keys
135
+ // array. Sets FLAG_RELINK on the node if keys changed.
136
+ function syncKeys(nextKeys: string[]): void {
137
+ if (!keysEqual(keys, nextKeys)) {
138
+ const a = new Set(keys)
139
+ const b = new Set(nextKeys)
140
+
141
+ for (const key of keys) if (!b.has(key)) signals.delete(key)
142
+ for (const key of nextKeys) if (!a.has(key)) addSignal(key)
143
+ keys = nextKeys
144
+ node.flags |= FLAG_RELINK
145
+ }
146
+ }
147
+
148
+ // Build current value from child signals.
149
+ // Reads source.keys() to sync the signals map and — during refresh() —
125
150
  // 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)
151
+ function buildValue(): T[] {
152
+ syncKeys(Array.from(source.keys()))
153
+ const result: T[] = []
154
+ for (const key of keys) {
155
+ try {
156
+ const v = signals.get(key)?.get()
157
+ if (v != null) result.push(v)
158
+ } catch (e) {
159
+ // Skip pending async items; rethrow real errors
160
+ if (!(e instanceof UnsetSignalValueError)) throw e
161
+ }
140
162
  }
163
+ return result
164
+ }
141
165
 
142
- return newKeys
166
+ // Shallow reference equality for value arrays — prevents unnecessary
167
+ // downstream propagation when re-evaluation produces the same item references
168
+ const valuesEqual = (a: T[], b: T[]): boolean => {
169
+ if (a.length !== b.length) return false
170
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
171
+ return true
143
172
  }
144
173
 
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,
174
+ // Structural tracking node — mirrors the List/Store/createCollection pattern.
175
+ // fn (buildValue) syncs keys then reads child signals to produce T[].
176
+ // Keys are tracked separately in a local variable.
177
+ const node: MemoNode<T[]> = {
178
+ fn: buildValue,
150
179
  value: [],
151
180
  flags: FLAG_DIRTY,
152
181
  sources: null,
153
182
  sourcesTail: null,
154
183
  sinks: null,
155
184
  sinksTail: null,
156
- equals: keysEqual,
185
+ equals: valuesEqual,
157
186
  error: undefined,
158
187
  }
159
188
 
160
- // Ensure keys are synced, using the same pattern as List/Store
161
- function ensureSynced(): string[] {
189
+ function ensureFresh(): void {
162
190
  if (node.sources) {
163
191
  if (node.flags) {
164
- node.value = untrack(syncKeys)
165
- node.flags = FLAG_CLEAN
192
+ node.value = untrack(buildValue)
193
+ if (node.flags & FLAG_RELINK) {
194
+ // Keys changed — new child signals need graph edges.
195
+ // Tracked recompute so link() adds new edges and
196
+ // trimSources() removes stale ones without orphaning.
197
+ node.flags = FLAG_DIRTY
198
+ refresh(node as unknown as SinkNode)
199
+ if (node.error) throw node.error
200
+ } else {
201
+ node.flags = FLAG_CLEAN
202
+ }
166
203
  }
167
- } else {
204
+ } else if (node.sinks) {
205
+ // First access with a downstream subscriber — use refresh()
206
+ // to establish graph edges via recomputeMemo
168
207
  refresh(node as unknown as SinkNode)
169
208
  if (node.error) throw node.error
209
+ } else {
210
+ // No subscribers yet (e.g., chained deriveCollection init) —
211
+ // compute value without establishing graph edges to prevent
212
+ // premature watched activation on upstream sources.
213
+ // Keep FLAG_DIRTY so the first refresh() with a real subscriber
214
+ // will establish proper graph edges.
215
+ node.value = untrack(buildValue)
170
216
  }
171
- return node.value
172
217
  }
173
218
 
174
- // Initialize signals for current source keys
175
- const initialKeys = Array.from(source.keys())
219
+ // Initialize signals for current source keys — untrack to prevent
220
+ // triggering watched callbacks on upstream sources during construction.
221
+ // The first refresh() (triggered by an effect) will establish proper
222
+ // graph edges; this just populates the signals map for direct access.
223
+ const initialKeys = Array.from(untrack(() => source.keys()))
176
224
  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
225
+ keys = initialKeys
226
+ // Keep FLAG_DIRTY so the first refresh() establishes edges.
179
227
 
180
228
  const collection: Collection<T> = {
181
229
  [Symbol.toStringTag]: TYPE_COLLECTION,
182
230
  [Symbol.isConcatSpreadable]: true as const,
183
231
 
184
232
  *[Symbol.iterator]() {
185
- for (const key of node.value) {
233
+ for (const key of keys) {
186
234
  const signal = signals.get(key)
187
235
  if (signal) yield signal
188
236
  }
@@ -190,32 +238,24 @@ function deriveCollection<T extends {}, U extends {}>(
190
238
 
191
239
  get length() {
192
240
  if (activeSink) link(node, activeSink)
193
- return ensureSynced().length
241
+ ensureFresh()
242
+ return keys.length
194
243
  },
195
244
 
196
245
  keys() {
197
246
  if (activeSink) link(node, activeSink)
198
- return ensureSynced().values()
247
+ ensureFresh()
248
+ return keys.values()
199
249
  },
200
250
 
201
251
  get() {
202
252
  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
253
+ ensureFresh()
254
+ return node.value
215
255
  },
216
256
 
217
257
  at(index: number) {
218
- return signals.get(node.value[index])
258
+ return signals.get(keys[index])
219
259
  },
220
260
 
221
261
  byKey(key: string) {
@@ -223,11 +263,11 @@ function deriveCollection<T extends {}, U extends {}>(
223
263
  },
224
264
 
225
265
  keyAt(index: number) {
226
- return node.value[index]
266
+ return keys[index]
227
267
  },
228
268
 
229
269
  indexOfKey(key: string) {
230
- return node.value.indexOf(key)
270
+ return keys.indexOf(key)
231
271
  },
232
272
 
233
273
  deriveCollection<R extends {}>(
@@ -247,37 +287,32 @@ function deriveCollection<T extends {}, U extends {}>(
247
287
 
248
288
  /**
249
289
  * Creates an externally-driven Collection with a watched lifecycle.
250
- * Items are managed by the start callback via `applyChanges(diffResult)`.
290
+ * Items are managed via the `applyChanges(changes)` helper passed to the watched callback.
251
291
  * The collection activates when first accessed by an effect and deactivates when no longer watched.
252
292
  *
253
293
  * @since 0.18.0
254
- * @param start - Callback invoked when the collection starts being watched, receives applyChanges helper
294
+ * @param watched - Callback invoked when the collection starts being watched, receives applyChanges helper
255
295
  * @param options - Optional configuration including initial value, key generation, and item signal creation
256
296
  * @returns A read-only Collection signal
257
297
  */
258
298
  function createCollection<T extends {}>(
259
- start: CollectionCallback,
299
+ watched: CollectionCallback<T>,
260
300
  options?: CollectionOptions<T>,
261
301
  ): Collection<T> {
262
- const initialValue = options?.value ?? []
263
- if (initialValue.length)
264
- validateSignalValue(TYPE_COLLECTION, initialValue, Array.isArray)
265
- validateCallback(TYPE_COLLECTION, start)
302
+ const value = options?.value ?? []
303
+ if (value.length) validateSignalValue(TYPE_COLLECTION, value, Array.isArray)
304
+ validateCallback(TYPE_COLLECTION, watched, isSyncFunction)
266
305
 
267
306
  const signals = new Map<string, Signal<T>>()
268
307
  const keys: string[] = []
308
+ const itemToKey = new Map<T, string>()
269
309
 
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++)
310
+ const [generateKey, contentBased] = getKeyGenerator(options?.keyConfig)
278
311
 
279
- const itemFactory =
280
- options?.createItem ?? ((_key: string, value: T) => createState(value))
312
+ const resolveKey = (item: T): string | undefined =>
313
+ itemToKey.get(item) ?? (contentBased ? generateKey(item) : undefined)
314
+
315
+ const itemFactory = options?.createItem ?? createState
281
316
 
282
317
  // Build current value from child signals
283
318
  function buildValue(): T[] {
@@ -296,68 +331,83 @@ function createCollection<T extends {}>(
296
331
 
297
332
  const node: MemoNode<T[]> = {
298
333
  fn: buildValue,
299
- value: initialValue,
334
+ value,
300
335
  flags: FLAG_DIRTY,
301
336
  sources: null,
302
337
  sourcesTail: null,
303
338
  sinks: null,
304
339
  sinksTail: null,
305
- equals: () => false, // Always rebuild — structural changes are managed externally
340
+ equals: SKIP_EQUALITY, // Always rebuild — structural changes are managed externally
306
341
  error: undefined,
307
342
  }
308
343
 
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
344
  // Initialize signals for initial value
351
- for (const item of initialValue) {
345
+ for (const item of value) {
352
346
  const key = generateKey(item)
353
- signals.set(key, itemFactory(key, item))
347
+ signals.set(key, itemFactory(item))
348
+ itemToKey.set(item, key)
354
349
  keys.push(key)
355
350
  }
356
- node.value = initialValue
351
+ node.value = value
357
352
  node.flags = FLAG_DIRTY // First refresh() will establish child edges
358
353
 
359
- function startWatching(): void {
360
- if (!node.sinks) node.stop = start(applyChanges)
354
+ function subscribe(): void {
355
+ if (activeSink) {
356
+ if (!node.sinks)
357
+ node.stop = watched((changes: CollectionChanges<T>): void => {
358
+ const { add, change, remove } = changes
359
+ if (!add?.length && !change?.length && !remove?.length)
360
+ return
361
+ let structural = false
362
+
363
+ batch(() => {
364
+ // Additions
365
+ if (add) {
366
+ for (const item of add) {
367
+ const key = generateKey(item)
368
+ signals.set(key, itemFactory(item))
369
+ itemToKey.set(item, key)
370
+ if (!keys.includes(key)) keys.push(key)
371
+ structural = true
372
+ }
373
+ }
374
+
375
+ // Changes — only for State signals
376
+ if (change) {
377
+ for (const item of change) {
378
+ const key = resolveKey(item)
379
+ if (!key) continue
380
+ const signal = signals.get(key)
381
+ if (signal && isState(signal)) {
382
+ // Update reverse map: remove old reference, add new
383
+ itemToKey.delete(signal.get())
384
+ signal.set(item)
385
+ itemToKey.set(item, key)
386
+ }
387
+ }
388
+ }
389
+
390
+ // Removals
391
+ if (remove) {
392
+ for (const item of remove) {
393
+ const key = resolveKey(item)
394
+ if (!key) continue
395
+ itemToKey.delete(item)
396
+ signals.delete(key)
397
+ const index = keys.indexOf(key)
398
+ if (index !== -1) keys.splice(index, 1)
399
+ structural = true
400
+ }
401
+ }
402
+
403
+ // Mark DIRTY so next get() rebuilds; propagate to sinks
404
+ node.flags = FLAG_DIRTY | (structural ? FLAG_RELINK : 0)
405
+ for (let e = node.sinks; e; e = e.nextSink)
406
+ propagate(e.sink)
407
+ })
408
+ })
409
+ link(node, activeSink)
410
+ }
361
411
  }
362
412
 
363
413
  const collection: Collection<T> = {
@@ -372,30 +422,31 @@ function createCollection<T extends {}>(
372
422
  },
373
423
 
374
424
  get length() {
375
- if (activeSink) {
376
- startWatching()
377
- link(node, activeSink)
378
- }
425
+ subscribe()
379
426
  return keys.length
380
427
  },
381
428
 
382
429
  keys() {
383
- if (activeSink) {
384
- startWatching()
385
- link(node, activeSink)
386
- }
430
+ subscribe()
387
431
  return keys.values()
388
432
  },
389
433
 
390
434
  get() {
391
- if (activeSink) {
392
- startWatching()
393
- link(node, activeSink)
394
- }
435
+ subscribe()
395
436
  if (node.sources) {
396
437
  if (node.flags) {
438
+ const relink = node.flags & FLAG_RELINK
397
439
  node.value = untrack(buildValue)
398
- node.flags = FLAG_CLEAN
440
+ if (relink) {
441
+ // Structural mutation added/removed child signals —
442
+ // tracked recompute so link() adds new edges and
443
+ // trimSources() removes stale ones without orphaning.
444
+ node.flags = FLAG_DIRTY
445
+ refresh(node as unknown as SinkNode)
446
+ if (node.error) throw node.error
447
+ } else {
448
+ node.flags = FLAG_CLEAN
449
+ }
399
450
  }
400
451
  } else {
401
452
  refresh(node as unknown as SinkNode)
@@ -468,6 +519,7 @@ export {
468
519
  isCollectionSource,
469
520
  type Collection,
470
521
  type CollectionCallback,
522
+ type CollectionChanges,
471
523
  type CollectionOptions,
472
524
  type CollectionSource,
473
525
  type DeriveCollectionCallback,