@zeix/cause-effect 0.18.0 → 0.18.1

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