@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,7 +15,7 @@ import {
15
15
  TYPE_STORE,
16
16
  untrack,
17
17
  } from '../graph'
18
- import { isFunction, isObjectOfType, isRecord } from '../util'
18
+ import { isObjectOfType, isRecord } from '../util'
19
19
  import {
20
20
  createList,
21
21
  type DiffResult,
@@ -51,8 +51,8 @@ type BaseStore<T extends UnknownRecord> = {
51
51
  ? State<T[K] & {}>
52
52
  : State<T[K] & {}> | undefined
53
53
  get(): T
54
- set(newValue: T): void
55
- update(fn: (oldValue: T) => T): void
54
+ set(next: T): void
55
+ update(fn: (prev: T) => T): void
56
56
  add<K extends keyof T & string>(key: K, value: T[K]): K
57
57
  remove(key: string): void
58
58
  }
@@ -70,21 +70,18 @@ type Store<T extends UnknownRecord> = BaseStore<T> & {
70
70
  /* === Functions === */
71
71
 
72
72
  /** Diff two records and return granular changes */
73
- function diffRecords<T extends UnknownRecord>(
74
- oldObj: T,
75
- newObj: T,
76
- ): DiffResult {
73
+ function diffRecords<T extends UnknownRecord>(prev: T, next: T): DiffResult {
77
74
  // Guard against non-objects that can't be diffed properly with Object.keys and 'in' operator
78
- const oldValid = isRecord(oldObj) || Array.isArray(oldObj)
79
- const newValid = isRecord(newObj) || Array.isArray(newObj)
80
- if (!oldValid || !newValid) {
75
+ const prevValid = isRecord(prev) || Array.isArray(prev)
76
+ const nextValid = isRecord(next) || Array.isArray(next)
77
+ if (!prevValid || !nextValid) {
81
78
  // For non-objects or non-plain objects, treat as complete change if different
82
- const changed = !Object.is(oldObj, newObj)
79
+ const changed = !Object.is(prev, next)
83
80
  return {
84
81
  changed,
85
- add: changed && newValid ? newObj : {},
82
+ add: changed && nextValid ? next : {},
86
83
  change: {},
87
- remove: changed && oldValid ? oldObj : {},
84
+ remove: changed && prevValid ? prev : {},
88
85
  }
89
86
  }
90
87
 
@@ -95,25 +92,25 @@ function diffRecords<T extends UnknownRecord>(
95
92
  const remove = {} as UnknownRecord
96
93
  let changed = false
97
94
 
98
- const oldKeys = Object.keys(oldObj)
99
- const newKeys = Object.keys(newObj)
95
+ const prevKeys = Object.keys(prev)
96
+ const nextKeys = Object.keys(next)
100
97
 
101
98
  // Pass 1: iterate new keys — find additions and changes
102
- for (const key of newKeys) {
103
- if (key in oldObj) {
104
- if (!isEqual(oldObj[key], newObj[key], visited)) {
105
- change[key] = newObj[key]
99
+ for (const key of nextKeys) {
100
+ if (key in prev) {
101
+ if (!isEqual(prev[key], next[key], visited)) {
102
+ change[key] = next[key]
106
103
  changed = true
107
104
  }
108
105
  } else {
109
- add[key] = newObj[key]
106
+ add[key] = next[key]
110
107
  changed = true
111
108
  }
112
109
  }
113
110
 
114
111
  // Pass 2: iterate old keys — find removals
115
- for (const key of oldKeys) {
116
- if (!(key in newObj)) {
112
+ for (const key of prevKeys) {
113
+ if (!(key in next)) {
117
114
  remove[key] = undefined
118
115
  changed = true
119
116
  }
@@ -128,7 +125,7 @@ function diffRecords<T extends UnknownRecord>(
128
125
  * Properties are accessible directly via proxy.
129
126
  *
130
127
  * @since 0.15.0
131
- * @param initialValue - Initial object value of the store
128
+ * @param value - Initial object value of the store
132
129
  * @param options - Optional configuration for watch lifecycle
133
130
  * @returns A Store with reactive properties
134
131
  *
@@ -140,10 +137,10 @@ function diffRecords<T extends UnknownRecord>(
140
137
  * ```
141
138
  */
142
139
  function createStore<T extends UnknownRecord>(
143
- initialValue: T,
140
+ value: T,
144
141
  options?: StoreOptions,
145
142
  ): Store<T> {
146
- validateSignalValue(TYPE_STORE, initialValue, isRecord)
143
+ validateSignalValue(TYPE_STORE, value, isRecord)
147
144
 
148
145
  const signals = new Map<
149
146
  string,
@@ -152,11 +149,11 @@ function createStore<T extends UnknownRecord>(
152
149
 
153
150
  // --- Internal helpers ---
154
151
 
155
- const addSignal = (key: string, value: unknown): void => {
156
- validateSignalValue(`${TYPE_STORE} for key "${key}"`, value)
157
- if (Array.isArray(value)) signals.set(key, createList(value))
158
- else if (isRecord(value)) signals.set(key, createStore(value))
159
- else signals.set(key, createState(value as unknown & {}))
152
+ const addSignal = (key: string, val: unknown): void => {
153
+ validateSignalValue(`${TYPE_STORE} for key "${key}"`, val)
154
+ if (Array.isArray(val)) signals.set(key, createList(val))
155
+ else if (isRecord(val)) signals.set(key, createStore(val))
156
+ else signals.set(key, createState(val as unknown & {}))
160
157
  }
161
158
 
162
159
  // Build current value from child signals
@@ -174,7 +171,7 @@ function createStore<T extends UnknownRecord>(
174
171
  // Mutation methods (add/remove/set) null out sources to force re-establishment.
175
172
  const node: MemoNode<T> = {
176
173
  fn: buildValue,
177
- value: initialValue,
174
+ value,
178
175
  flags: FLAG_DIRTY,
179
176
  sources: null,
180
177
  sourcesTail: null,
@@ -197,15 +194,15 @@ function createStore<T extends UnknownRecord>(
197
194
  if (Object.keys(changes.change).length) {
198
195
  batch(() => {
199
196
  for (const key in changes.change) {
200
- const value = changes.change[key]
201
- validateSignalValue(`${TYPE_STORE} for key "${key}"`, value)
197
+ const val = changes.change[key]
198
+ validateSignalValue(`${TYPE_STORE} for key "${key}"`, val)
202
199
  const signal = signals.get(key)
203
200
  if (signal) {
204
201
  // Type changed (e.g. primitive → object or vice versa): replace signal
205
- if (isRecord(value) !== isStore(signal)) {
206
- addSignal(key, value)
202
+ if (isRecord(val) !== isStore(signal)) {
203
+ addSignal(key, val)
207
204
  structural = true
208
- } else signal.set(value as never)
205
+ } else signal.set(val as never)
209
206
  }
210
207
  }
211
208
  })
@@ -225,9 +222,20 @@ function createStore<T extends UnknownRecord>(
225
222
  return changes.changed
226
223
  }
227
224
 
225
+ const watched = options?.watched
226
+ const subscribe = watched
227
+ ? () => {
228
+ if (activeSink) {
229
+ if (!node.sinks) node.stop = watched()
230
+ link(node, activeSink)
231
+ }
232
+ }
233
+ : () => {
234
+ if (activeSink) link(node, activeSink)
235
+ }
236
+
228
237
  // --- Initialize ---
229
- for (const key of Object.keys(initialValue))
230
- addSignal(key, initialValue[key])
238
+ for (const key of Object.keys(value)) addSignal(key, value[key])
231
239
 
232
240
  // --- Store object ---
233
241
  const store: BaseStore<T> = {
@@ -250,11 +258,7 @@ function createStore<T extends UnknownRecord>(
250
258
  },
251
259
 
252
260
  keys() {
253
- if (activeSink) {
254
- if (!node.sinks && options?.watched)
255
- node.stop = options.watched()
256
- link(node, activeSink)
257
- }
261
+ subscribe()
258
262
  return signals.keys()
259
263
  },
260
264
 
@@ -270,11 +274,7 @@ function createStore<T extends UnknownRecord>(
270
274
  },
271
275
 
272
276
  get() {
273
- if (activeSink) {
274
- if (!node.sinks && options?.watched)
275
- node.stop = options.watched()
276
- link(node, activeSink)
277
- }
277
+ subscribe()
278
278
  if (node.sources) {
279
279
  // Fast path: edges already established, rebuild value directly
280
280
  // from child signals using untrack to avoid creating spurious
@@ -291,16 +291,14 @@ function createStore<T extends UnknownRecord>(
291
291
  return node.value
292
292
  },
293
293
 
294
- set(newValue: T) {
294
+ set(next: T) {
295
295
  // Use cached value if clean, recompute if dirty
296
- const currentValue =
297
- node.flags & FLAG_DIRTY ? buildValue() : node.value
296
+ const prev = node.flags & FLAG_DIRTY ? buildValue() : node.value
298
297
 
299
- const changes = diffRecords(currentValue, newValue)
298
+ const changes = diffRecords(prev, next)
300
299
  if (applyChanges(changes)) {
301
- // Call propagate BEFORE marking dirty to ensure it doesn't early-return
302
- propagate(node as unknown as SinkNode)
303
300
  node.flags |= FLAG_DIRTY
301
+ for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
304
302
  if (batchDepth === 0) flush()
305
303
  }
306
304
  },
@@ -315,8 +313,8 @@ function createStore<T extends UnknownRecord>(
315
313
  addSignal(key, value)
316
314
  node.sources = null
317
315
  node.sourcesTail = null
318
- propagate(node as unknown as SinkNode)
319
316
  node.flags |= FLAG_DIRTY
317
+ for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
320
318
  if (batchDepth === 0) flush()
321
319
  return key
322
320
  },
@@ -326,8 +324,8 @@ function createStore<T extends UnknownRecord>(
326
324
  if (ok) {
327
325
  node.sources = null
328
326
  node.sourcesTail = null
329
- propagate(node as unknown as SinkNode)
330
327
  node.flags |= FLAG_DIRTY
328
+ for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
331
329
  if (batchDepth === 0) flush()
332
330
  }
333
331
  },
@@ -336,10 +334,7 @@ function createStore<T extends UnknownRecord>(
336
334
  // --- Proxy ---
337
335
  return new Proxy(store, {
338
336
  get(target, prop) {
339
- if (prop in target) {
340
- const value = Reflect.get(target, prop)
341
- return isFunction(value) ? value.bind(target) : value
342
- }
337
+ if (prop in target) return Reflect.get(target, prop)
343
338
  if (typeof prop !== 'symbol')
344
339
  return target.byKey(prop as keyof T & string)
345
340
  },
package/src/nodes/task.ts CHANGED
@@ -5,10 +5,13 @@ import {
5
5
  } from '../errors'
6
6
  import {
7
7
  activeSink,
8
+ batchDepth,
8
9
  type ComputedOptions,
9
- defaultEquals,
10
+ DEFAULT_EQUALITY,
10
11
  FLAG_DIRTY,
12
+ flush,
11
13
  link,
14
+ propagate,
12
15
  refresh,
13
16
  type SinkNode,
14
17
  type TaskCallback,
@@ -62,6 +65,12 @@ type Task<T extends {}> = {
62
65
  * @template T - The type of value resolved by the task
63
66
  * @param fn - The async computation function that receives the previous value and an AbortSignal
64
67
  * @param options - Optional configuration for the task
68
+ * @param options.value - Optional initial value for reducer patterns
69
+ * @param options.equals - Optional equality function. Defaults to strict equality (`===`)
70
+ * @param options.guard - Optional type guard to validate values
71
+ * @param options.watched - Optional callback invoked when the task is first watched by an effect.
72
+ * Receives an `invalidate` function to mark the task dirty and trigger re-execution.
73
+ * Must return a cleanup function called when no effects are watching.
65
74
  * @returns A Task object with get(), isPending(), and abort() methods
66
75
  *
67
76
  * @example
@@ -108,15 +117,34 @@ function createTask<T extends {}>(
108
117
  sinks: null,
109
118
  sinksTail: null,
110
119
  flags: FLAG_DIRTY,
111
- equals: options?.equals ?? defaultEquals,
120
+ equals: options?.equals ?? DEFAULT_EQUALITY,
112
121
  controller: undefined,
113
122
  error: undefined,
123
+ stop: undefined,
114
124
  }
115
125
 
126
+ const watched = options?.watched
127
+ const subscribe = watched
128
+ ? () => {
129
+ if (activeSink) {
130
+ if (!node.sinks)
131
+ node.stop = watched(() => {
132
+ node.flags |= FLAG_DIRTY
133
+ for (let e = node.sinks; e; e = e.nextSink)
134
+ propagate(e.sink)
135
+ if (batchDepth === 0) flush()
136
+ })
137
+ link(node, activeSink)
138
+ }
139
+ }
140
+ : () => {
141
+ if (activeSink) link(node, activeSink)
142
+ }
143
+
116
144
  return {
117
145
  [Symbol.toStringTag]: TYPE_TASK,
118
146
  get(): T {
119
- if (activeSink) link(node, activeSink)
147
+ subscribe()
120
148
  refresh(node as unknown as SinkNode)
121
149
  if (node.error) throw node.error
122
150
  validateReadValue(TYPE_TASK, node.value)
@@ -1,12 +1,12 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import {
3
3
  batch,
4
+ type CollectionChanges,
4
5
  createCollection,
5
6
  createEffect,
6
7
  createList,
7
8
  createScope,
8
9
  createState,
9
- type DiffResult,
10
10
  isCollection,
11
11
  isList,
12
12
  } from '../index.ts'
@@ -88,7 +88,7 @@ describe('Collection', () => {
88
88
  let guardCalled = false
89
89
  const col = createCollection(() => () => {}, {
90
90
  value: [5, 10],
91
- createItem: (_key, value) =>
91
+ createItem: value =>
92
92
  createState(value, {
93
93
  guard: (v): v is number => {
94
94
  guardCalled = true
@@ -174,7 +174,9 @@ describe('Collection', () => {
174
174
 
175
175
  describe('applyChanges', () => {
176
176
  test('should add items', () => {
177
- let apply: ((changes: DiffResult) => void) | undefined
177
+ let apply:
178
+ | ((changes: CollectionChanges<number>) => void)
179
+ | undefined
178
180
  const col = createCollection<number>(applyChanges => {
179
181
  apply = applyChanges
180
182
  return () => {}
@@ -190,12 +192,7 @@ describe('Collection', () => {
190
192
  expect(values).toEqual([[]])
191
193
 
192
194
  // biome-ignore lint/style/noNonNullAssertion: test
193
- apply!({
194
- changed: true,
195
- add: { a: 1, b: 2 },
196
- change: {},
197
- remove: {},
198
- })
195
+ apply!({ add: [1, 2] })
199
196
 
200
197
  expect(values.length).toBe(2)
201
198
  expect(values[1]).toEqual([1, 2])
@@ -205,7 +202,11 @@ describe('Collection', () => {
205
202
  })
206
203
 
207
204
  test('should change item values', () => {
208
- let apply: ((changes: DiffResult) => void) | undefined
205
+ let apply:
206
+ | ((
207
+ changes: CollectionChanges<{ id: string; val: number }>,
208
+ ) => void)
209
+ | undefined
209
210
  const col = createCollection(
210
211
  applyChanges => {
211
212
  apply = applyChanges
@@ -227,12 +228,7 @@ describe('Collection', () => {
227
228
  expect(values[0]).toEqual([{ id: 'x', val: 1 }])
228
229
 
229
230
  // biome-ignore lint/style/noNonNullAssertion: test
230
- apply!({
231
- changed: true,
232
- add: {},
233
- change: { x: { id: 'x', val: 42 } },
234
- remove: {},
235
- })
231
+ apply!({ change: [{ id: 'x', val: 42 }] })
236
232
 
237
233
  expect(values.length).toBe(2)
238
234
  expect(values[1]).toEqual([{ id: 'x', val: 42 }])
@@ -241,7 +237,11 @@ describe('Collection', () => {
241
237
  })
242
238
 
243
239
  test('should remove items', () => {
244
- let apply: ((changes: DiffResult) => void) | undefined
240
+ let apply:
241
+ | ((
242
+ changes: CollectionChanges<{ id: string; v: number }>,
243
+ ) => void)
244
+ | undefined
245
245
  const col = createCollection(
246
246
  applyChanges => {
247
247
  apply = applyChanges
@@ -271,12 +271,7 @@ describe('Collection', () => {
271
271
  ])
272
272
 
273
273
  // biome-ignore lint/style/noNonNullAssertion: test
274
- apply!({
275
- changed: true,
276
- add: {},
277
- change: {},
278
- remove: { b: null },
279
- })
274
+ apply!({ remove: [{ id: 'b', v: 2 }] })
280
275
 
281
276
  expect(values.length).toBe(2)
282
277
  expect(values[1]).toEqual([
@@ -289,7 +284,11 @@ describe('Collection', () => {
289
284
  })
290
285
 
291
286
  test('should handle mixed add/change/remove', () => {
292
- let apply: ((changes: DiffResult) => void) | undefined
287
+ let apply:
288
+ | ((
289
+ changes: CollectionChanges<{ id: string; v: number }>,
290
+ ) => void)
291
+ | undefined
293
292
  const col = createCollection(
294
293
  applyChanges => {
295
294
  apply = applyChanges
@@ -313,10 +312,9 @@ describe('Collection', () => {
313
312
 
314
313
  // biome-ignore lint/style/noNonNullAssertion: test
315
314
  apply!({
316
- changed: true,
317
- add: { c: { id: 'c', v: 3 } },
318
- change: { a: { id: 'a', v: 10 } },
319
- remove: { b: null },
315
+ add: [{ id: 'c', v: 3 }],
316
+ change: [{ id: 'a', v: 10 }],
317
+ remove: [{ id: 'b', v: 2 }],
320
318
  })
321
319
 
322
320
  expect(values.length).toBe(2)
@@ -328,8 +326,10 @@ describe('Collection', () => {
328
326
  dispose()
329
327
  })
330
328
 
331
- test('should skip when changed is false', () => {
332
- let apply: ((changes: DiffResult) => void) | undefined
329
+ test('should skip when no changes provided', () => {
330
+ let apply:
331
+ | ((changes: CollectionChanges<number>) => void)
332
+ | undefined
333
333
  const col = createCollection(
334
334
  applyChanges => {
335
335
  apply = applyChanges
@@ -349,7 +349,7 @@ describe('Collection', () => {
349
349
  expect(callCount).toBe(1)
350
350
 
351
351
  // biome-ignore lint/style/noNonNullAssertion: test
352
- apply!({ changed: false, add: {}, change: {}, remove: {} })
352
+ apply!({})
353
353
 
354
354
  expect(callCount).toBe(1)
355
355
 
@@ -357,7 +357,9 @@ describe('Collection', () => {
357
357
  })
358
358
 
359
359
  test('should trigger effects on structural changes', () => {
360
- let apply: ((changes: DiffResult) => void) | undefined
360
+ let apply:
361
+ | ((changes: CollectionChanges<string>) => void)
362
+ | undefined
361
363
  const col = createCollection<string>(applyChanges => {
362
364
  apply = applyChanges
363
365
  return () => {}
@@ -374,12 +376,7 @@ describe('Collection', () => {
374
376
  expect(effectCount).toBe(1)
375
377
 
376
378
  // biome-ignore lint/style/noNonNullAssertion: test
377
- apply!({
378
- changed: true,
379
- add: { a: 'hello' },
380
- change: {},
381
- remove: {},
382
- })
379
+ apply!({ add: ['hello'] })
383
380
 
384
381
  expect(effectCount).toBe(2)
385
382
  expect(col.length).toBe(1)
@@ -388,7 +385,9 @@ describe('Collection', () => {
388
385
  })
389
386
 
390
387
  test('should batch multiple calls', () => {
391
- let apply: ((changes: DiffResult) => void) | undefined
388
+ let apply:
389
+ | ((changes: CollectionChanges<number>) => void)
390
+ | undefined
392
391
  const col = createCollection<number>(applyChanges => {
393
392
  apply = applyChanges
394
393
  return () => {}
@@ -406,19 +405,9 @@ describe('Collection', () => {
406
405
 
407
406
  batch(() => {
408
407
  // biome-ignore lint/style/noNonNullAssertion: test
409
- apply!({
410
- changed: true,
411
- add: { a: 1 },
412
- change: {},
413
- remove: {},
414
- })
408
+ apply!({ add: [1] })
415
409
  // biome-ignore lint/style/noNonNullAssertion: test
416
- apply!({
417
- changed: true,
418
- add: { b: 2 },
419
- change: {},
420
- remove: {},
421
- })
410
+ apply!({ add: [2] })
422
411
  })
423
412
 
424
413
  expect(effectCount).toBe(2)