@zeix/cause-effect 0.14.2 → 0.15.0

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,325 @@
1
+ import { diff, type UnknownRecord } 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 } from './util'
13
+
14
+ /* === Constants === */
15
+
16
+ const TYPE_STORE = 'Store'
17
+
18
+ /* === Types === */
19
+
20
+ interface StoreAddEvent<T extends UnknownRecord> extends CustomEvent {
21
+ type: 'store-add'
22
+ detail: Partial<T>
23
+ }
24
+
25
+ interface StoreChangeEvent<T extends UnknownRecord> extends CustomEvent {
26
+ type: 'store-change'
27
+ detail: Partial<T>
28
+ }
29
+
30
+ interface StoreRemoveEvent<T extends UnknownRecord> extends CustomEvent {
31
+ type: 'store-remove'
32
+ detail: Partial<T>
33
+ }
34
+
35
+ type StoreEventMap<T extends UnknownRecord> = {
36
+ 'store-add': StoreAddEvent<T>
37
+ 'store-change': StoreChangeEvent<T>
38
+ 'store-remove': StoreRemoveEvent<T>
39
+ }
40
+
41
+ interface StoreEventTarget<T extends UnknownRecord> 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 UnknownRecord = UnknownRecord> = {
68
+ [K in keyof T & string]: T[K] extends UnknownRecord
69
+ ? Store<T[K]>
70
+ : State<T[K]>
71
+ } & StoreEventTarget<T> & {
72
+ [Symbol.toStringTag]: 'Store'
73
+ [Symbol.iterator](): IterableIterator<[string, Signal<T[keyof T]>]>
74
+
75
+ // Signal methods
76
+ add<K extends keyof T & string>(key: K, value: T[K]): void
77
+ get(): T
78
+ remove<K extends keyof T & string>(key: K): void
79
+ set(value: T): void
80
+ update(updater: (value: T) => T): void
81
+
82
+ // Interals signals
83
+ size: State<number>
84
+ }
85
+
86
+ /* === Functions === */
87
+
88
+ /**
89
+ * Create a new store with deeply nested reactive properties
90
+ *
91
+ * @since 0.15.0
92
+ * @param {T} initialValue - initial object value of the store
93
+ * @returns {Store<T>} - new store with reactive properties
94
+ */
95
+ const store = <T extends UnknownRecord>(initialValue: T): Store<T> => {
96
+ const watchers: Set<Watcher> = new Set()
97
+ const eventTarget = new EventTarget()
98
+ const signals: Map<
99
+ keyof T & string,
100
+ Store<T[keyof T & string]> | State<T[keyof T & string]>
101
+ > = new Map()
102
+ const cleanups = new Map<keyof T & string, Cleanup>()
103
+
104
+ // Internal state
105
+ const size = state(0)
106
+
107
+ // Get current record
108
+ const current = () => {
109
+ const record: Partial<T> = {}
110
+ for (const [key, value] of signals) {
111
+ record[key] = value.get()
112
+ }
113
+ return record as T
114
+ }
115
+
116
+ // Emit event
117
+ const emit = (type: keyof StoreEventMap<T>, detail: Partial<T>) =>
118
+ eventTarget.dispatchEvent(new CustomEvent(type, { detail }))
119
+
120
+ // Add nested signal and effect
121
+ const addSignalAndEffect = <K extends keyof T & string>(
122
+ key: K,
123
+ value: T[K],
124
+ ) => {
125
+ const signal = toMutableSignal<T[keyof T & string]>(value)
126
+ signals.set(key, signal)
127
+ const cleanup = effect(() => {
128
+ const value = signal.get()
129
+ if (value != null)
130
+ emit('store-change', { [key]: value } as unknown as Partial<T>)
131
+ })
132
+ cleanups.set(key, cleanup)
133
+ }
134
+
135
+ // Remove nested signal and effect
136
+ const removeSignalAndEffect = <K extends keyof T & string>(key: K) => {
137
+ signals.delete(key)
138
+ const cleanup = cleanups.get(key)
139
+ if (cleanup) cleanup()
140
+ cleanups.delete(key)
141
+ }
142
+
143
+ // Reconcile data and dispatch events
144
+ const reconcile = (oldValue: T, newValue: T): boolean => {
145
+ const changes = diff(oldValue, newValue)
146
+
147
+ batch(() => {
148
+ if (Object.keys(changes.add).length) {
149
+ for (const key in changes.add) {
150
+ const value = changes.add[key]
151
+ if (value != null) addSignalAndEffect(key, value)
152
+ }
153
+ emit('store-add', changes.add)
154
+ }
155
+ if (Object.keys(changes.change).length) {
156
+ for (const key in changes.change) {
157
+ const signal = signals.get(key as keyof T & string)
158
+ const value = changes.change[key]
159
+ if (
160
+ signal &&
161
+ value != null &&
162
+ hasMethod<Signal<T[keyof T & string]>>(signal, 'set')
163
+ )
164
+ signal.set(value)
165
+ }
166
+ emit('store-change', changes.change)
167
+ }
168
+ if (Object.keys(changes.remove).length) {
169
+ for (const key in changes.remove) {
170
+ removeSignalAndEffect(key)
171
+ }
172
+ emit('store-remove', changes.remove)
173
+ }
174
+
175
+ size.set(signals.size)
176
+ })
177
+
178
+ return changes.changed
179
+ }
180
+
181
+ // Initialize data
182
+ reconcile({} as T, initialValue)
183
+
184
+ // Queue initial additions event to allow listeners to be added first
185
+ setTimeout(() => {
186
+ const initialAdditionsEvent = new CustomEvent('store-add', {
187
+ detail: initialValue as Partial<T>,
188
+ }) as StoreAddEvent<T>
189
+ eventTarget.dispatchEvent(initialAdditionsEvent)
190
+ }, 0)
191
+
192
+ const storeProps = [
193
+ 'add',
194
+ 'get',
195
+ 'remove',
196
+ 'set',
197
+ 'update',
198
+ 'addEventListener',
199
+ 'removeEventListener',
200
+ 'dispatchEvent',
201
+ 'size',
202
+ ]
203
+
204
+ // Return proxy directly with integrated signal methods
205
+ return new Proxy({} as Store<T>, {
206
+ get(_target, prop) {
207
+ const key = String(prop)
208
+
209
+ // Handle signal methods and size property
210
+ switch (prop) {
211
+ case 'add':
212
+ return <K extends keyof T & string>(
213
+ k: K,
214
+ v: T[K],
215
+ ): void => {
216
+ if (!signals.has(k)) {
217
+ addSignalAndEffect(k, v)
218
+ notify(watchers)
219
+ emit('store-add', {
220
+ [k]: v,
221
+ } as unknown as Partial<T>)
222
+ size.set(signals.size)
223
+ }
224
+ }
225
+ case 'get':
226
+ return (): T => {
227
+ subscribe(watchers)
228
+ return current()
229
+ }
230
+ case 'remove':
231
+ return <K extends keyof T & string>(k: K): void => {
232
+ if (signals.has(k)) {
233
+ removeSignalAndEffect(k)
234
+ notify(watchers)
235
+ emit('store-remove', { [k]: UNSET } as Partial<T>)
236
+ size.set(signals.size)
237
+ }
238
+ }
239
+ case 'set':
240
+ return (v: T): void => {
241
+ if (reconcile(current(), v)) {
242
+ notify(watchers)
243
+ if (UNSET === v) watchers.clear()
244
+ }
245
+ }
246
+ case 'update':
247
+ return (fn: (v: T) => T): void => {
248
+ const oldValue = current()
249
+ const newValue = fn(oldValue)
250
+ if (reconcile(oldValue, newValue)) {
251
+ notify(watchers)
252
+ if (UNSET === newValue) watchers.clear()
253
+ }
254
+ }
255
+ case 'addEventListener':
256
+ return eventTarget.addEventListener.bind(eventTarget)
257
+ case 'removeEventListener':
258
+ return eventTarget.removeEventListener.bind(eventTarget)
259
+ case 'dispatchEvent':
260
+ return eventTarget.dispatchEvent.bind(eventTarget)
261
+ case 'size':
262
+ return size
263
+ }
264
+
265
+ // Handle symbol properties
266
+ if (prop === Symbol.toStringTag) return TYPE_STORE
267
+ if (prop === Symbol.iterator) {
268
+ return function* () {
269
+ for (const [key, signal] of signals) {
270
+ yield [key, signal as Signal<T[keyof T]>]
271
+ }
272
+ }
273
+ }
274
+
275
+ // Handle data properties - return signals
276
+ return signals.get(key)
277
+ },
278
+ has(_target, prop) {
279
+ const key = String(prop)
280
+ return (
281
+ signals.has(key) ||
282
+ storeProps.includes(key) ||
283
+ prop === Symbol.toStringTag ||
284
+ prop === Symbol.iterator
285
+ )
286
+ },
287
+ ownKeys() {
288
+ return Array.from(signals.keys())
289
+ },
290
+ getOwnPropertyDescriptor(_target, prop) {
291
+ const signal = signals.get(String(prop))
292
+ return signal
293
+ ? {
294
+ enumerable: true,
295
+ configurable: true,
296
+ writable: true,
297
+ value: signal,
298
+ }
299
+ : undefined
300
+ },
301
+ })
302
+ }
303
+
304
+ /**
305
+ * Check if the provided value is a Store instance
306
+ *
307
+ * @since 0.15.0
308
+ * @param {unknown} value - value to check
309
+ * @returns {boolean} - true if the value is a Store instance, false otherwise
310
+ */
311
+ const isStore = <T extends UnknownRecord>(value: unknown): value is Store<T> =>
312
+ isObjectOfType(value, TYPE_STORE)
313
+
314
+ /* === Exports === */
315
+
316
+ export {
317
+ TYPE_STORE,
318
+ isStore,
319
+ store,
320
+ type Store,
321
+ type StoreAddEvent,
322
+ type StoreChangeEvent,
323
+ type StoreRemoveEvent,
324
+ type StoreEventMap,
325
+ }
package/src/util.ts CHANGED
@@ -1,15 +1,54 @@
1
1
  /* === Utility Functions === */
2
2
 
3
+ const isNumber = /*#__PURE__*/ (value: unknown): value is number =>
4
+ typeof value === 'number'
5
+
6
+ const isString = /*#__PURE__*/ (value: unknown): value is string =>
7
+ typeof value === 'string'
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 isPrimitive = /*#__PURE__*/ (value: unknown): boolean =>
28
+ typeof value !== 'object' && !isFunction(value)
29
+
30
+ const arrayToRecord = /*#__PURE__*/ <T extends unknown & {}>(
31
+ array: T[],
32
+ ): Record<string, T> => {
33
+ const record: Record<string, T> = {}
34
+ for (let i = 0; i < array.length; i++) {
35
+ if (i in array) record[String(i)] = array[i]
36
+ }
37
+ return record
38
+ }
39
+
40
+ const hasMethod = /*#__PURE__*/ <
41
+ T extends object & Record<string, (...args: unknown[]) => unknown>,
42
+ >(
43
+ obj: T,
44
+ methodName: string,
45
+ ): obj is T & Record<string, (...args: unknown[]) => unknown> =>
46
+ methodName in obj && isFunction(obj[methodName])
47
+
48
+ const isAbortError = /*#__PURE__*/ (error: unknown): boolean =>
49
+ error instanceof DOMException && error.name === 'AbortError'
50
+
51
+ const toError = /*#__PURE__*/ (reason: unknown): Error =>
13
52
  reason instanceof Error ? reason : Error(String(reason))
14
53
 
15
54
  class CircularDependencyError extends Error {
@@ -21,4 +60,17 @@ class CircularDependencyError extends Error {
21
60
 
22
61
  /* === Exports === */
23
62
 
24
- export { isFunction, isObjectOfType, toError, CircularDependencyError }
63
+ export {
64
+ isNumber,
65
+ isString,
66
+ isFunction,
67
+ isAsyncFunction,
68
+ isObjectOfType,
69
+ isRecord,
70
+ isPrimitive,
71
+ arrayToRecord,
72
+ hasMethod,
73
+ isAbortError,
74
+ toError,
75
+ CircularDependencyError,
76
+ }
@@ -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)