@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.
@@ -6,6 +6,7 @@ import {
6
6
  type Cleanup,
7
7
  FLAG_CLEAN,
8
8
  FLAG_DIRTY,
9
+ FLAG_RELINK,
9
10
  flush,
10
11
  link,
11
12
  type MemoNode,
@@ -15,7 +16,7 @@ import {
15
16
  TYPE_STORE,
16
17
  untrack,
17
18
  } from '../graph'
18
- import { isFunction, isObjectOfType, isRecord } from '../util'
19
+ import { isObjectOfType, isRecord } from '../util'
19
20
  import {
20
21
  createList,
21
22
  type DiffResult,
@@ -51,8 +52,8 @@ type BaseStore<T extends UnknownRecord> = {
51
52
  ? State<T[K] & {}>
52
53
  : State<T[K] & {}> | undefined
53
54
  get(): T
54
- set(newValue: T): void
55
- update(fn: (oldValue: T) => T): void
55
+ set(next: T): void
56
+ update(fn: (prev: T) => T): void
56
57
  add<K extends keyof T & string>(key: K, value: T[K]): K
57
58
  remove(key: string): void
58
59
  }
@@ -70,21 +71,18 @@ type Store<T extends UnknownRecord> = BaseStore<T> & {
70
71
  /* === Functions === */
71
72
 
72
73
  /** Diff two records and return granular changes */
73
- function diffRecords<T extends UnknownRecord>(
74
- oldObj: T,
75
- newObj: T,
76
- ): DiffResult {
74
+ function diffRecords<T extends UnknownRecord>(prev: T, next: T): DiffResult {
77
75
  // 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) {
76
+ const prevValid = isRecord(prev) || Array.isArray(prev)
77
+ const nextValid = isRecord(next) || Array.isArray(next)
78
+ if (!prevValid || !nextValid) {
81
79
  // For non-objects or non-plain objects, treat as complete change if different
82
- const changed = !Object.is(oldObj, newObj)
80
+ const changed = !Object.is(prev, next)
83
81
  return {
84
82
  changed,
85
- add: changed && newValid ? newObj : {},
83
+ add: changed && nextValid ? next : {},
86
84
  change: {},
87
- remove: changed && oldValid ? oldObj : {},
85
+ remove: changed && prevValid ? prev : {},
88
86
  }
89
87
  }
90
88
 
@@ -95,25 +93,25 @@ function diffRecords<T extends UnknownRecord>(
95
93
  const remove = {} as UnknownRecord
96
94
  let changed = false
97
95
 
98
- const oldKeys = Object.keys(oldObj)
99
- const newKeys = Object.keys(newObj)
96
+ const prevKeys = Object.keys(prev)
97
+ const nextKeys = Object.keys(next)
100
98
 
101
99
  // 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]
100
+ for (const key of nextKeys) {
101
+ if (key in prev) {
102
+ if (!isEqual(prev[key], next[key], visited)) {
103
+ change[key] = next[key]
106
104
  changed = true
107
105
  }
108
106
  } else {
109
- add[key] = newObj[key]
107
+ add[key] = next[key]
110
108
  changed = true
111
109
  }
112
110
  }
113
111
 
114
112
  // Pass 2: iterate old keys — find removals
115
- for (const key of oldKeys) {
116
- if (!(key in newObj)) {
113
+ for (const key of prevKeys) {
114
+ if (!(key in next)) {
117
115
  remove[key] = undefined
118
116
  changed = true
119
117
  }
@@ -128,7 +126,7 @@ function diffRecords<T extends UnknownRecord>(
128
126
  * Properties are accessible directly via proxy.
129
127
  *
130
128
  * @since 0.15.0
131
- * @param initialValue - Initial object value of the store
129
+ * @param value - Initial object value of the store
132
130
  * @param options - Optional configuration for watch lifecycle
133
131
  * @returns A Store with reactive properties
134
132
  *
@@ -140,10 +138,10 @@ function diffRecords<T extends UnknownRecord>(
140
138
  * ```
141
139
  */
142
140
  function createStore<T extends UnknownRecord>(
143
- initialValue: T,
141
+ value: T,
144
142
  options?: StoreOptions,
145
143
  ): Store<T> {
146
- validateSignalValue(TYPE_STORE, initialValue, isRecord)
144
+ validateSignalValue(TYPE_STORE, value, isRecord)
147
145
 
148
146
  const signals = new Map<
149
147
  string,
@@ -152,11 +150,11 @@ function createStore<T extends UnknownRecord>(
152
150
 
153
151
  // --- Internal helpers ---
154
152
 
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 & {}))
153
+ const addSignal = (key: string, val: unknown): void => {
154
+ validateSignalValue(`${TYPE_STORE} for key "${key}"`, val)
155
+ if (Array.isArray(val)) signals.set(key, createList(val))
156
+ else if (isRecord(val)) signals.set(key, createStore(val))
157
+ else signals.set(key, createState(val as unknown & {}))
160
158
  }
161
159
 
162
160
  // Build current value from child signals
@@ -171,10 +169,10 @@ function createStore<T extends UnknownRecord>(
171
169
  // Structural tracking node — not a general-purpose Memo.
172
170
  // On first get(): refresh() establishes edges from child signals.
173
171
  // On subsequent get(): untrack(buildValue) rebuilds without re-linking.
174
- // Mutation methods (add/remove/set) null out sources to force re-establishment.
172
+ // Mutation methods set FLAG_RELINK to force re-establishment on next read.
175
173
  const node: MemoNode<T> = {
176
174
  fn: buildValue,
177
- value: initialValue,
175
+ value,
178
176
  flags: FLAG_DIRTY,
179
177
  sources: null,
180
178
  sourcesTail: null,
@@ -197,15 +195,15 @@ function createStore<T extends UnknownRecord>(
197
195
  if (Object.keys(changes.change).length) {
198
196
  batch(() => {
199
197
  for (const key in changes.change) {
200
- const value = changes.change[key]
201
- validateSignalValue(`${TYPE_STORE} for key "${key}"`, value)
198
+ const val = changes.change[key]
199
+ validateSignalValue(`${TYPE_STORE} for key "${key}"`, val)
202
200
  const signal = signals.get(key)
203
201
  if (signal) {
204
202
  // Type changed (e.g. primitive → object or vice versa): replace signal
205
- if (isRecord(value) !== isStore(signal)) {
206
- addSignal(key, value)
203
+ if (isRecord(val) !== isStore(signal)) {
204
+ addSignal(key, val)
207
205
  structural = true
208
- } else signal.set(value as never)
206
+ } else signal.set(val as never)
209
207
  }
210
208
  }
211
209
  })
@@ -217,17 +215,25 @@ function createStore<T extends UnknownRecord>(
217
215
  structural = true
218
216
  }
219
217
 
220
- if (structural) {
221
- node.sources = null
222
- node.sourcesTail = null
223
- }
218
+ if (structural) node.flags |= FLAG_RELINK
224
219
 
225
220
  return changes.changed
226
221
  }
227
222
 
223
+ const watched = options?.watched
224
+ const subscribe = watched
225
+ ? () => {
226
+ if (activeSink) {
227
+ if (!node.sinks) node.stop = watched()
228
+ link(node, activeSink)
229
+ }
230
+ }
231
+ : () => {
232
+ if (activeSink) link(node, activeSink)
233
+ }
234
+
228
235
  // --- Initialize ---
229
- for (const key of Object.keys(initialValue))
230
- addSignal(key, initialValue[key])
236
+ for (const key of Object.keys(value)) addSignal(key, value[key])
231
237
 
232
238
  // --- Store object ---
233
239
  const store: BaseStore<T> = {
@@ -250,11 +256,7 @@ function createStore<T extends UnknownRecord>(
250
256
  },
251
257
 
252
258
  keys() {
253
- if (activeSink) {
254
- if (!node.sinks && options?.watched)
255
- node.stop = options.watched()
256
- link(node, activeSink)
257
- }
259
+ subscribe()
258
260
  return signals.keys()
259
261
  },
260
262
 
@@ -270,18 +272,24 @@ function createStore<T extends UnknownRecord>(
270
272
  },
271
273
 
272
274
  get() {
273
- if (activeSink) {
274
- if (!node.sinks && options?.watched)
275
- node.stop = options.watched()
276
- link(node, activeSink)
277
- }
275
+ subscribe()
278
276
  if (node.sources) {
279
277
  // Fast path: edges already established, rebuild value directly
280
278
  // from child signals using untrack to avoid creating spurious
281
279
  // edges to the current effect/memo consumer
282
280
  if (node.flags) {
281
+ const relink = node.flags & FLAG_RELINK
283
282
  node.value = untrack(buildValue)
284
- node.flags = FLAG_CLEAN
283
+ if (relink) {
284
+ // Structural mutation added/removed child signals —
285
+ // tracked recompute so link() adds new edges and
286
+ // trimSources() removes stale ones without orphaning.
287
+ node.flags = FLAG_DIRTY
288
+ refresh(node as unknown as SinkNode)
289
+ if (node.error) throw node.error
290
+ } else {
291
+ node.flags = FLAG_CLEAN
292
+ }
285
293
  }
286
294
  } else {
287
295
  // First access: use refresh() to establish child → store edges
@@ -291,16 +299,14 @@ function createStore<T extends UnknownRecord>(
291
299
  return node.value
292
300
  },
293
301
 
294
- set(newValue: T) {
302
+ set(next: T) {
295
303
  // Use cached value if clean, recompute if dirty
296
- const currentValue =
297
- node.flags & FLAG_DIRTY ? buildValue() : node.value
304
+ const prev = node.flags & FLAG_DIRTY ? buildValue() : node.value
298
305
 
299
- const changes = diffRecords(currentValue, newValue)
306
+ const changes = diffRecords(prev, next)
300
307
  if (applyChanges(changes)) {
301
- // Call propagate BEFORE marking dirty to ensure it doesn't early-return
302
- propagate(node as unknown as SinkNode)
303
308
  node.flags |= FLAG_DIRTY
309
+ for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
304
310
  if (batchDepth === 0) flush()
305
311
  }
306
312
  },
@@ -313,10 +319,8 @@ function createStore<T extends UnknownRecord>(
313
319
  if (signals.has(key))
314
320
  throw new DuplicateKeyError(TYPE_STORE, key, value)
315
321
  addSignal(key, value)
316
- node.sources = null
317
- node.sourcesTail = null
318
- propagate(node as unknown as SinkNode)
319
- node.flags |= FLAG_DIRTY
322
+ node.flags |= FLAG_DIRTY | FLAG_RELINK
323
+ for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
320
324
  if (batchDepth === 0) flush()
321
325
  return key
322
326
  },
@@ -324,10 +328,8 @@ function createStore<T extends UnknownRecord>(
324
328
  remove(key: string) {
325
329
  const ok = signals.delete(key)
326
330
  if (ok) {
327
- node.sources = null
328
- node.sourcesTail = null
329
- propagate(node as unknown as SinkNode)
330
- node.flags |= FLAG_DIRTY
331
+ node.flags |= FLAG_DIRTY | FLAG_RELINK
332
+ for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
331
333
  if (batchDepth === 0) flush()
332
334
  }
333
335
  },
@@ -336,10 +338,7 @@ function createStore<T extends UnknownRecord>(
336
338
  // --- Proxy ---
337
339
  return new Proxy(store, {
338
340
  get(target, prop) {
339
- if (prop in target) {
340
- const value = Reflect.get(target, prop)
341
- return isFunction(value) ? value.bind(target) : value
342
- }
341
+ if (prop in target) return Reflect.get(target, prop)
343
342
  if (typeof prop !== 'symbol')
344
343
  return target.byKey(prop as keyof T & string)
345
344
  },
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)