@zeix/cause-effect 0.14.2 → 0.15.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.
package/src/store.ts ADDED
@@ -0,0 +1,324 @@
1
+ import { diff, type UnknownRecord, type UnknownRecordOrArray } from './diff'
2
+ import { effect } from './effect'
3
+ import {
4
+ batch,
5
+ type Cleanup,
6
+ notify,
7
+ subscribe,
8
+ type Watcher,
9
+ } from './scheduler'
10
+ import { type Signal, toMutableSignal, UNSET } from './signal'
11
+ import { type State, state } from './state'
12
+ import { hasMethod, isObjectOfType, validArrayIndexes } from './util'
13
+
14
+ /* === Constants === */
15
+
16
+ const TYPE_STORE = 'Store'
17
+
18
+ /* === Types === */
19
+
20
+ interface StoreAddEvent<T extends UnknownRecordOrArray> extends CustomEvent {
21
+ type: 'store-add'
22
+ detail: Partial<T>
23
+ }
24
+
25
+ interface StoreChangeEvent<T extends UnknownRecordOrArray> extends CustomEvent {
26
+ type: 'store-change'
27
+ detail: Partial<T>
28
+ }
29
+
30
+ interface StoreRemoveEvent<T extends UnknownRecordOrArray> extends CustomEvent {
31
+ type: 'store-remove'
32
+ detail: Partial<T>
33
+ }
34
+
35
+ type StoreEventMap<T extends UnknownRecordOrArray> = {
36
+ 'store-add': StoreAddEvent<T>
37
+ 'store-change': StoreChangeEvent<T>
38
+ 'store-remove': StoreRemoveEvent<T>
39
+ }
40
+
41
+ interface StoreEventTarget<T extends UnknownRecordOrArray> extends EventTarget {
42
+ addEventListener<K extends keyof StoreEventMap<T>>(
43
+ type: K,
44
+ listener: (event: StoreEventMap<T>[K]) => void,
45
+ options?: boolean | AddEventListenerOptions,
46
+ ): void
47
+ addEventListener(
48
+ type: string,
49
+ listener: EventListenerOrEventListenerObject,
50
+ options?: boolean | AddEventListenerOptions,
51
+ ): void
52
+
53
+ removeEventListener<K extends keyof StoreEventMap<T>>(
54
+ type: K,
55
+ listener: (event: StoreEventMap<T>[K]) => void,
56
+ options?: boolean | EventListenerOptions,
57
+ ): void
58
+ removeEventListener(
59
+ type: string,
60
+ listener: EventListenerOrEventListenerObject,
61
+ options?: boolean | EventListenerOptions,
62
+ ): void
63
+
64
+ dispatchEvent(event: Event): boolean
65
+ }
66
+
67
+ type Store<T extends UnknownRecordOrArray = UnknownRecord> = {
68
+ [K in keyof T]: T[K] extends UnknownRecord ? Store<T[K]> : State<T[K]>
69
+ } & StoreEventTarget<T> & {
70
+ [Symbol.toStringTag]: 'Store'
71
+ [Symbol.iterator](): IterableIterator<[keyof T, Signal<T[keyof T]>]>
72
+
73
+ add<K extends keyof T>(key: K, value: T[K]): void
74
+ get(): T
75
+ remove<K extends keyof T>(key: K): void
76
+ set(value: T): void
77
+ update(updater: (value: T) => T): void
78
+ size: State<number>
79
+ }
80
+
81
+ /* === Functions === */
82
+
83
+ /**
84
+ * Create a new store with deeply nested reactive properties
85
+ *
86
+ * Supports both objects and arrays as initial values. Arrays are converted
87
+ * to records internally for storage but maintain their array type through
88
+ * the .get() method, which automatically converts objects with consecutive
89
+ * numeric keys back to arrays.
90
+ *
91
+ * @since 0.15.0
92
+ * @param {T} initialValue - initial object or array value of the store
93
+ * @returns {Store<T>} - new store with reactive properties that preserves the original type T
94
+ */
95
+ const store = <T extends UnknownRecordOrArray>(initialValue: T): Store<T> => {
96
+ const watchers: Set<Watcher> = new Set()
97
+ const eventTarget = new EventTarget()
98
+ const signals: Map<keyof T, Store<T[keyof T]> | State<T[keyof T]>> =
99
+ new Map()
100
+ const cleanups = new Map<keyof T, Cleanup>()
101
+
102
+ // Internal state
103
+ const size = state(0)
104
+
105
+ // Get current record or array
106
+ const current = (): T => {
107
+ const keys = Array.from(signals.keys())
108
+ const arrayIndexes = validArrayIndexes(keys)
109
+ if (arrayIndexes)
110
+ return arrayIndexes.map(index =>
111
+ signals.get(String(index))?.get(),
112
+ ) as unknown as T
113
+ const record: Partial<T> = {}
114
+ for (const [key, signal] of signals) {
115
+ record[key] = signal.get()
116
+ }
117
+ return record as T
118
+ }
119
+
120
+ // Emit event
121
+ const emit = (type: keyof StoreEventMap<T>, detail: Partial<T>) =>
122
+ eventTarget.dispatchEvent(new CustomEvent(type, { detail }))
123
+
124
+ // Add nested signal and effect
125
+ const addProperty = <K extends keyof T>(key: K, value: T[K]) => {
126
+ const stringKey = String(key)
127
+ const signal = toMutableSignal(value)
128
+ signals.set(stringKey, signal as Store<T[keyof T]> | State<T[keyof T]>)
129
+ const cleanup = effect(() => {
130
+ const currentValue = signal.get()
131
+ if (currentValue != null)
132
+ emit('store-change', { [key]: currentValue } as Partial<T>)
133
+ })
134
+ cleanups.set(stringKey, cleanup)
135
+ }
136
+
137
+ // Remove nested signal and effect
138
+ const removeProperty = <K extends keyof T>(key: K) => {
139
+ const stringKey = String(key)
140
+ signals.delete(stringKey)
141
+ const cleanup = cleanups.get(stringKey)
142
+ if (cleanup) cleanup()
143
+ cleanups.delete(stringKey)
144
+ }
145
+
146
+ // Reconcile data and dispatch events
147
+ const reconcile = (oldValue: T, newValue: T): boolean => {
148
+ const changes = diff(oldValue, newValue)
149
+
150
+ batch(() => {
151
+ if (Object.keys(changes.add).length) {
152
+ for (const key in changes.add) {
153
+ const value = changes.add[key]
154
+ if (value != null) addProperty(key, value as T[keyof T])
155
+ }
156
+ emit('store-add', changes.add)
157
+ }
158
+ if (Object.keys(changes.change).length) {
159
+ for (const key in changes.change) {
160
+ const signal = signals.get(key)
161
+ const value = changes.change[key]
162
+ if (
163
+ signal &&
164
+ value != null &&
165
+ hasMethod<Signal<T[keyof T]>>(signal, 'set')
166
+ )
167
+ signal.set(value)
168
+ }
169
+ emit('store-change', changes.change)
170
+ }
171
+ if (Object.keys(changes.remove).length) {
172
+ for (const key in changes.remove) {
173
+ removeProperty(key)
174
+ }
175
+ emit('store-remove', changes.remove)
176
+ }
177
+
178
+ size.set(signals.size)
179
+ })
180
+
181
+ return changes.changed
182
+ }
183
+
184
+ // Initialize data
185
+ reconcile({} as T, initialValue)
186
+
187
+ // Queue initial additions event to allow listeners to be added first
188
+ setTimeout(() => {
189
+ const initialAdditionsEvent = new CustomEvent('store-add', {
190
+ detail: initialValue,
191
+ })
192
+ eventTarget.dispatchEvent(initialAdditionsEvent)
193
+ }, 0)
194
+
195
+ const storeProps = [
196
+ 'add',
197
+ 'get',
198
+ 'remove',
199
+ 'set',
200
+ 'update',
201
+ 'addEventListener',
202
+ 'removeEventListener',
203
+ 'dispatchEvent',
204
+ 'size',
205
+ ]
206
+
207
+ // Return proxy directly with integrated signal methods
208
+ return new Proxy({} as Store<T>, {
209
+ get(_target, prop) {
210
+ // Handle signal methods and size property
211
+ switch (prop) {
212
+ case 'add':
213
+ return <K extends keyof T>(k: K, v: T[K]): void => {
214
+ if (!signals.has(k)) {
215
+ addProperty(k, v)
216
+ notify(watchers)
217
+ emit('store-add', {
218
+ [k]: v,
219
+ } as unknown as Partial<T>)
220
+ size.set(signals.size)
221
+ }
222
+ }
223
+ case 'get':
224
+ return (): T => {
225
+ subscribe(watchers)
226
+ return current()
227
+ }
228
+ case 'remove':
229
+ return <K extends keyof T>(k: K): void => {
230
+ if (signals.has(k)) {
231
+ removeProperty(k)
232
+ notify(watchers)
233
+ emit('store-remove', { [k]: UNSET } as Partial<T>)
234
+ size.set(signals.size)
235
+ }
236
+ }
237
+ case 'set':
238
+ return (v: T): void => {
239
+ if (reconcile(current(), v)) {
240
+ notify(watchers)
241
+ if (UNSET === v) watchers.clear()
242
+ }
243
+ }
244
+ case 'update':
245
+ return (fn: (v: T) => T): void => {
246
+ const oldValue = current()
247
+ const newValue = fn(oldValue)
248
+ if (reconcile(oldValue, newValue)) {
249
+ notify(watchers)
250
+ if (UNSET === newValue) watchers.clear()
251
+ }
252
+ }
253
+ case 'addEventListener':
254
+ return eventTarget.addEventListener.bind(eventTarget)
255
+ case 'removeEventListener':
256
+ return eventTarget.removeEventListener.bind(eventTarget)
257
+ case 'dispatchEvent':
258
+ return eventTarget.dispatchEvent.bind(eventTarget)
259
+ case 'size':
260
+ return size
261
+ }
262
+
263
+ // Handle symbol properties
264
+ if (prop === Symbol.toStringTag) return TYPE_STORE
265
+ if (prop === Symbol.iterator) {
266
+ return function* () {
267
+ for (const [key, signal] of signals) {
268
+ yield [key, signal]
269
+ }
270
+ }
271
+ }
272
+
273
+ // Handle data properties - return signals
274
+ return signals.get(String(prop))
275
+ },
276
+ has(_target, prop) {
277
+ const key = String(prop)
278
+ return (
279
+ signals.has(key) ||
280
+ storeProps.includes(key) ||
281
+ prop === Symbol.toStringTag ||
282
+ prop === Symbol.iterator
283
+ )
284
+ },
285
+ ownKeys() {
286
+ return Array.from(signals.keys()).map(key => String(key))
287
+ },
288
+ getOwnPropertyDescriptor(_target, prop) {
289
+ const signal = signals.get(String(prop))
290
+ return signal
291
+ ? {
292
+ enumerable: true,
293
+ configurable: true,
294
+ writable: true,
295
+ value: signal,
296
+ }
297
+ : undefined
298
+ },
299
+ })
300
+ }
301
+
302
+ /**
303
+ * Check if the provided value is a Store instance
304
+ *
305
+ * @since 0.15.0
306
+ * @param {unknown} value - value to check
307
+ * @returns {boolean} - true if the value is a Store instance, false otherwise
308
+ */
309
+ const isStore = <T extends UnknownRecordOrArray>(
310
+ value: unknown,
311
+ ): value is Store<T> => isObjectOfType(value, TYPE_STORE)
312
+
313
+ /* === Exports === */
314
+
315
+ export {
316
+ TYPE_STORE,
317
+ isStore,
318
+ store,
319
+ type Store,
320
+ type StoreAddEvent,
321
+ type StoreChangeEvent,
322
+ type StoreRemoveEvent,
323
+ type StoreEventMap,
324
+ }
package/src/util.ts CHANGED
@@ -1,15 +1,53 @@
1
1
  /* === Utility Functions === */
2
2
 
3
+ const isString = /*#__PURE__*/ (value: unknown): value is string =>
4
+ typeof value === 'string'
5
+
6
+ const isNumber = /*#__PURE__*/ (value: unknown): value is number =>
7
+ typeof value === 'number'
8
+
3
9
  const isFunction = /*#__PURE__*/ <T>(
4
- value: unknown,
5
- ): value is (...args: unknown[]) => T => typeof value === 'function'
10
+ fn: unknown,
11
+ ): fn is (...args: unknown[]) => T => typeof fn === 'function'
12
+
13
+ const isAsyncFunction = /*#__PURE__*/ <T>(
14
+ fn: unknown,
15
+ ): fn is (...args: unknown[]) => Promise<T> =>
16
+ isFunction(fn) && fn.constructor.name === 'AsyncFunction'
6
17
 
7
18
  const isObjectOfType = /*#__PURE__*/ <T>(
8
19
  value: unknown,
9
20
  type: string,
10
21
  ): value is T => Object.prototype.toString.call(value) === `[object ${type}]`
11
22
 
12
- const toError = (reason: unknown): Error =>
23
+ const isRecord = /*#__PURE__*/ <T extends Record<string, unknown>>(
24
+ value: unknown,
25
+ ): value is T => isObjectOfType(value, 'Object')
26
+
27
+ const validArrayIndexes = /*#__PURE__*/ (
28
+ keys: Array<PropertyKey>,
29
+ ): number[] | null => {
30
+ if (!keys.length) return null
31
+ const indexes = keys.map(k =>
32
+ isString(k) ? parseInt(k, 10) : isNumber(k) ? k : NaN,
33
+ )
34
+ return indexes.every(index => Number.isFinite(index) && index >= 0)
35
+ ? indexes.sort((a, b) => a - b)
36
+ : null
37
+ }
38
+
39
+ const hasMethod = /*#__PURE__*/ <
40
+ T extends object & Record<string, (...args: unknown[]) => unknown>,
41
+ >(
42
+ obj: T,
43
+ methodName: string,
44
+ ): obj is T & Record<string, (...args: unknown[]) => unknown> =>
45
+ methodName in obj && isFunction(obj[methodName])
46
+
47
+ const isAbortError = /*#__PURE__*/ (error: unknown): boolean =>
48
+ error instanceof DOMException && error.name === 'AbortError'
49
+
50
+ const toError = /*#__PURE__*/ (reason: unknown): Error =>
13
51
  reason instanceof Error ? reason : Error(String(reason))
14
52
 
15
53
  class CircularDependencyError extends Error {
@@ -21,4 +59,16 @@ class CircularDependencyError extends Error {
21
59
 
22
60
  /* === Exports === */
23
61
 
24
- export { isFunction, isObjectOfType, toError, CircularDependencyError }
62
+ export {
63
+ isString,
64
+ isNumber,
65
+ isFunction,
66
+ isAsyncFunction,
67
+ isObjectOfType,
68
+ isRecord,
69
+ validArrayIndexes,
70
+ hasMethod,
71
+ isAbortError,
72
+ toError,
73
+ CircularDependencyError,
74
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { batch, computed, effect, state } from '../'
2
+ import { batch, computed, effect, match, resolve, state } from '../'
3
3
 
4
4
  /* === Tests === */
5
5
 
@@ -28,13 +28,15 @@ describe('Batch', () => {
28
28
  const sum = computed(() => a.get() + b.get() + c.get())
29
29
  let result = 0
30
30
  let count = 0
31
- effect({
32
- signals: [sum],
33
- ok: (res): undefined => {
34
- result = res
35
- count++
36
- },
37
- err: (): undefined => {},
31
+ effect(() => {
32
+ const resolved = resolve({ sum })
33
+ match(resolved, {
34
+ ok: ({ sum: res }) => {
35
+ result = res
36
+ count++
37
+ },
38
+ err: () => {},
39
+ })
38
40
  })
39
41
  batch(() => {
40
42
  a.set(6)
@@ -61,17 +63,19 @@ describe('Batch', () => {
61
63
  let errCount = 0
62
64
 
63
65
  // Effect: switch cases for the result
64
- effect({
65
- signals: [sum],
66
- ok: (v): undefined => {
67
- result = v
68
- okCount++
69
- // console.log('Sum:', v)
70
- },
71
- err: (_error): undefined => {
72
- errCount++
73
- // console.error('Error:', error)
74
- },
66
+ effect(() => {
67
+ const resolved = resolve({ sum })
68
+ match(resolved, {
69
+ ok: ({ sum: v }) => {
70
+ result = v
71
+ okCount++
72
+ // console.log('Sum:', v)
73
+ },
74
+ err: () => {
75
+ errCount++
76
+ // console.error('Error:', error)
77
+ },
78
+ })
75
79
  })
76
80
 
77
81
  expect(okCount).toBe(1)
@@ -169,7 +169,7 @@ describe('Basic test', () => {
169
169
  describe('Kairo tests', () => {
170
170
  const name = framework.name
171
171
 
172
- test(`${name} | avoidable propagation`, () => {
172
+ test(`${name} | avoidable propagation`, async () => {
173
173
  const head = framework.signal(0)
174
174
  const computed1 = framework.computed(() => head.read())
175
175
  const computed2 = framework.computed(() => {
@@ -201,7 +201,7 @@ describe('Kairo tests', () => {
201
201
  }
202
202
  })
203
203
 
204
- test(`${name} | broad propagation`, () => {
204
+ test(`${name} | broad propagation`, async () => {
205
205
  const head = framework.signal(0)
206
206
  let last = head as { read: () => number }
207
207
  const callCounter = new Counter()
@@ -235,7 +235,7 @@ describe('Kairo tests', () => {
235
235
  }
236
236
  })
237
237
 
238
- test(`${name} | deep propagation`, () => {
238
+ test(`${name} | deep propagation`, async () => {
239
239
  const len = 50
240
240
  const head = framework.signal(0)
241
241
  let current = head as { read: () => number }
@@ -268,7 +268,7 @@ describe('Kairo tests', () => {
268
268
  }
269
269
  })
270
270
 
271
- test(`${name} | diamond`, () => {
271
+ test(`${name} | diamond`, async () => {
272
272
  const width = 5
273
273
  const head = framework.signal(0)
274
274
  const current: { read(): number }[] = []
@@ -301,7 +301,7 @@ describe('Kairo tests', () => {
301
301
  }
302
302
  })
303
303
 
304
- test(`${name} | mux`, () => {
304
+ test(`${name} | mux`, async () => {
305
305
  const heads = new Array(100).fill(null).map(_ => framework.signal(0))
306
306
  const mux = framework.computed(() => {
307
307
  return Object.fromEntries(heads.map(h => h.read()).entries())
@@ -332,7 +332,7 @@ describe('Kairo tests', () => {
332
332
  }
333
333
  })
334
334
 
335
- test(`${name} | repeated observers`, () => {
335
+ test(`${name} | repeated observers`, async () => {
336
336
  const size = 30
337
337
  const head = framework.signal(0)
338
338
  const current = framework.computed(() => {
@@ -365,7 +365,7 @@ describe('Kairo tests', () => {
365
365
  }
366
366
  })
367
367
 
368
- test(`${name} | triangle`, () => {
368
+ test(`${name} | triangle`, async () => {
369
369
  const width = 10
370
370
  const head = framework.signal(0)
371
371
  let current = head as { read: () => number }
@@ -410,7 +410,7 @@ describe('Kairo tests', () => {
410
410
  }
411
411
  })
412
412
 
413
- test(`${name} | unstable`, () => {
413
+ test(`${name} | unstable`, async () => {
414
414
  const head = framework.signal(0)
415
415
  const double = framework.computed(() => head.read() * 2)
416
416
  const inverse = framework.computed(() => -head.read())
@@ -4,9 +4,11 @@ import {
4
4
  effect,
5
5
  isComputed,
6
6
  isState,
7
+ match,
8
+ resolve,
7
9
  state,
8
10
  UNSET,
9
- } from '../index.ts'
11
+ } from '../'
10
12
 
11
13
  /* === Utility Functions === */
12
14
 
@@ -134,7 +136,7 @@ describe('Computed', () => {
134
136
  const b = computed(() => x.get())
135
137
  const c = computed(() => {
136
138
  count++
137
- return a.get() + ' ' + b.get()
139
+ return `${a.get()} ${b.get()}`
138
140
  })
139
141
  expect(c.get()).toBe('a a')
140
142
  expect(count).toBe(1)
@@ -282,15 +284,17 @@ describe('Computed', () => {
282
284
  let okCount = 0
283
285
  let nilCount = 0
284
286
  let result: number = 0
285
- effect({
286
- signals: [derived],
287
- ok: (v): undefined => {
288
- result = v
289
- okCount++
290
- },
291
- nil: (): undefined => {
292
- nilCount++
293
- },
287
+ effect(() => {
288
+ const resolved = resolve({ derived })
289
+ match(resolved, {
290
+ ok: ({ derived: v }) => {
291
+ result = v
292
+ okCount++
293
+ },
294
+ nil: () => {
295
+ nilCount++
296
+ },
297
+ })
294
298
  })
295
299
  cause.set(43)
296
300
  expect(okCount).toBe(0)