@zeix/cause-effect 0.15.1 → 0.15.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.
package/src/util.ts CHANGED
@@ -1,3 +1,8 @@
1
+ /* === Constants === */
2
+
3
+ // biome-ignore lint/suspicious/noExplicitAny: Deliberately using any to be used as a placeholder value in any signal
4
+ const UNSET: any = Symbol()
5
+
1
6
  /* === Utility Functions === */
2
7
 
3
8
  const isString = /*#__PURE__*/ (value: unknown): value is string =>
@@ -6,6 +11,9 @@ const isString = /*#__PURE__*/ (value: unknown): value is string =>
6
11
  const isNumber = /*#__PURE__*/ (value: unknown): value is number =>
7
12
  typeof value === 'number'
8
13
 
14
+ const isSymbol = /*#__PURE__*/ (value: unknown): value is symbol =>
15
+ typeof value === 'symbol'
16
+
9
17
  const isFunction = /*#__PURE__*/ <T>(
10
18
  fn: unknown,
11
19
  ): fn is (...args: unknown[]) => T => typeof fn === 'function'
@@ -15,6 +23,10 @@ const isAsyncFunction = /*#__PURE__*/ <T>(
15
23
  ): fn is (...args: unknown[]) => Promise<T> =>
16
24
  isFunction(fn) && fn.constructor.name === 'AsyncFunction'
17
25
 
26
+ const isDefinedObject = /*#__PURE__*/ (
27
+ value: unknown,
28
+ ): value is Record<string, unknown> => !!value && typeof value === 'object'
29
+
18
30
  const isObjectOfType = /*#__PURE__*/ <T>(
19
31
  value: unknown,
20
32
  type: string,
@@ -24,6 +36,12 @@ const isRecord = /*#__PURE__*/ <T extends Record<string, unknown>>(
24
36
  value: unknown,
25
37
  ): value is T => isObjectOfType(value, 'Object')
26
38
 
39
+ const isRecordOrArray = /*#__PURE__*/ <
40
+ T extends Record<string | number, unknown> | ReadonlyArray<unknown>,
41
+ >(
42
+ value: unknown,
43
+ ): value is T => isRecord(value) || Array.isArray(value)
44
+
27
45
  const validArrayIndexes = /*#__PURE__*/ (
28
46
  keys: Array<PropertyKey>,
29
47
  ): number[] | null => {
@@ -50,25 +68,51 @@ const isAbortError = /*#__PURE__*/ (error: unknown): boolean =>
50
68
  const toError = /*#__PURE__*/ (reason: unknown): Error =>
51
69
  reason instanceof Error ? reason : Error(String(reason))
52
70
 
53
- class CircularDependencyError extends Error {
54
- constructor(where: string) {
55
- super(`Circular dependency in ${where} detected`)
56
- this.name = 'CircularDependencyError'
71
+ const arrayToRecord = /*#__PURE__*/ <T>(array: T[]): Record<string, T> => {
72
+ const record: Record<string, T> = {}
73
+ for (let i = 0; i < array.length; i++) {
74
+ record[String(i)] = array[i]
75
+ }
76
+ return record
77
+ }
78
+
79
+ const recordToArray = /*#__PURE__*/ <T>(
80
+ record: Record<string | number, T>,
81
+ ): Record<string, T> | T[] => {
82
+ const indexes = validArrayIndexes(Object.keys(record))
83
+ if (indexes === null) return record
84
+
85
+ const array: T[] = []
86
+ for (const index of indexes) {
87
+ array.push(record[String(index)])
57
88
  }
89
+ return array
58
90
  }
59
91
 
92
+ const valueString = /*#__PURE__*/ (value: unknown): string =>
93
+ isString(value)
94
+ ? `"${value}"`
95
+ : isDefinedObject(value)
96
+ ? JSON.stringify(value)
97
+ : String(value)
98
+
60
99
  /* === Exports === */
61
100
 
62
101
  export {
102
+ UNSET,
63
103
  isString,
64
104
  isNumber,
105
+ isSymbol,
65
106
  isFunction,
66
107
  isAsyncFunction,
108
+ isDefinedObject,
67
109
  isObjectOfType,
68
110
  isRecord,
69
- validArrayIndexes,
111
+ isRecordOrArray,
70
112
  hasMethod,
71
113
  isAbortError,
72
114
  toError,
73
- CircularDependencyError,
115
+ arrayToRecord,
116
+ recordToArray,
117
+ valueString,
74
118
  }
@@ -231,7 +231,7 @@ describe('Computed', () => {
231
231
  const c = computed(() => b.get() + a.get())
232
232
  expect(() => {
233
233
  b.get() // This should trigger the circular dependency
234
- }).toThrow('Circular dependency in computed detected')
234
+ }).toThrow('Circular dependency detected in computed')
235
235
  expect(a.get()).toBe(1)
236
236
  })
237
237
 
package/test/diff.test.ts CHANGED
@@ -23,7 +23,7 @@ describe('diff', () => {
23
23
  test('should detect additions', () => {
24
24
  const obj1 = { a: 1 }
25
25
  const obj2 = { a: 1, b: 'new' }
26
- const result = diff(obj1 as UnknownRecord, obj2 as UnknownRecord)
26
+ const result = diff<{ a: number; b?: string }>(obj1, obj2)
27
27
 
28
28
  expect(result.changed).toBe(true)
29
29
  expect(result.add).toEqual({ b: 'new' })
@@ -34,7 +34,7 @@ describe('diff', () => {
34
34
  test('should detect removals', () => {
35
35
  const obj1 = { a: 1, b: 'hello' }
36
36
  const obj2 = { a: 1 }
37
- const result = diff(obj1 as UnknownRecord, obj2 as UnknownRecord)
37
+ const result = diff<{ a: number; b?: string }>(obj1, obj2)
38
38
 
39
39
  expect(result.changed).toBe(true)
40
40
  expect(Object.keys(result.add)).toHaveLength(0)
@@ -56,7 +56,12 @@ describe('diff', () => {
56
56
  test('should detect multiple changes', () => {
57
57
  const obj1 = { a: 1, b: 'hello', c: true }
58
58
  const obj2 = { a: 2, d: 'new', c: true }
59
- const result = diff(obj1 as UnknownRecord, obj2 as UnknownRecord)
59
+ const result = diff<{
60
+ a: number
61
+ b?: string
62
+ c: boolean
63
+ d?: string
64
+ }>(obj1, obj2)
60
65
 
61
66
  expect(result.changed).toBe(true)
62
67
  expect(result.add).toEqual({ d: 'new' })
@@ -212,7 +217,10 @@ describe('diff', () => {
212
217
  test('should handle changes from primitive to object', () => {
213
218
  const obj1 = { value: 'string' }
214
219
  const obj2 = { value: { type: 'object' } }
215
- const result = diff(obj1 as UnknownRecord, obj2 as UnknownRecord)
220
+ const result = diff<{ value: string | { type: string } }>(
221
+ obj1,
222
+ obj2,
223
+ )
216
224
 
217
225
  expect(result.changed).toBe(true)
218
226
  expect(result.change).toEqual({ value: { type: 'object' } })
@@ -635,4 +643,313 @@ describe('diff', () => {
635
643
  })
636
644
  })
637
645
  })
646
+
647
+ describe('non-plain object type safety', () => {
648
+ test('should handle Symbol objects without throwing TypeError', () => {
649
+ const symbol = Symbol('test')
650
+ const obj = { a: 1 }
651
+
652
+ // These should not throw after we fix the bug
653
+ // @ts-expect-error Testing runtime behavior with non-plain object types
654
+ expect(() => diff(symbol, obj)).not.toThrow()
655
+ // @ts-expect-error Testing runtime behavior with non-plain object types
656
+ expect(() => diff(obj, symbol)).not.toThrow()
657
+ // @ts-expect-error Testing runtime behavior with non-plain object types
658
+ expect(() => isEqual(symbol, obj)).not.toThrow()
659
+ })
660
+
661
+ test('should report additions when diffing from Symbol to valid object', () => {
662
+ const symbol = Symbol('test')
663
+ const obj = { a: 1, b: 'hello' }
664
+
665
+ // @ts-expect-error Testing runtime behavior with non-plain object types
666
+ const result = diff(symbol, obj)
667
+
668
+ expect(result.changed).toBe(true)
669
+ expect(result.add).toEqual({ a: 1, b: 'hello' })
670
+ expect(result.change).toEqual({})
671
+ expect(result.remove).toEqual({})
672
+ })
673
+
674
+ test('should report removals when diffing from valid object to Symbol', () => {
675
+ const obj = { a: 1, b: 'hello' }
676
+ const symbol = Symbol('test')
677
+
678
+ // @ts-expect-error Testing runtime behavior with non-plain object types
679
+ const result = diff(obj, symbol)
680
+
681
+ expect(result.changed).toBe(true)
682
+ expect(result.add).toEqual({})
683
+ expect(result.change).toEqual({})
684
+ expect(result.remove).toEqual({ a: 1, b: 'hello' })
685
+ })
686
+
687
+ test('should handle Symbol to Symbol diff with no changes', () => {
688
+ const symbol = Symbol('test')
689
+
690
+ // @ts-expect-error Testing runtime behavior with non-plain object types
691
+ const result = diff(symbol, symbol)
692
+
693
+ expect(result.changed).toBe(false)
694
+ expect(result.add).toEqual({})
695
+ expect(result.change).toEqual({})
696
+ expect(result.remove).toEqual({})
697
+ })
698
+
699
+ test('should handle different Symbols as changed', () => {
700
+ const symbol1 = Symbol('test1')
701
+ const symbol2 = Symbol('test2')
702
+
703
+ // @ts-expect-error Testing runtime behavior with non-plain object types
704
+ const result = diff(symbol1, symbol2)
705
+
706
+ expect(result.changed).toBe(true)
707
+ expect(result.add).toEqual({})
708
+ expect(result.change).toEqual({})
709
+ expect(result.remove).toEqual({})
710
+ })
711
+
712
+ test('should handle Date objects without throwing TypeError', () => {
713
+ const date = new Date('2023-01-01')
714
+ const obj = { a: 1 }
715
+
716
+ // @ts-expect-error Testing runtime behavior with non-plain object types
717
+ expect(() => diff(date, obj)).not.toThrow()
718
+ // @ts-expect-error Testing runtime behavior with non-plain object types
719
+ expect(() => diff(obj, date)).not.toThrow()
720
+ // @ts-expect-error Testing runtime behavior with non-plain object types
721
+ expect(() => isEqual(date, obj)).not.toThrow()
722
+ })
723
+
724
+ test('should report additions when diffing from Date to valid object', () => {
725
+ const date = new Date('2023-01-01')
726
+ const obj = { a: 1, b: 'hello' }
727
+
728
+ // @ts-expect-error Testing runtime behavior with non-plain object types
729
+ const result = diff(date, obj)
730
+
731
+ expect(result.changed).toBe(true)
732
+ expect(result.add).toEqual({ a: 1, b: 'hello' })
733
+ expect(result.change).toEqual({})
734
+ expect(result.remove).toEqual({})
735
+ })
736
+
737
+ test('should report removals when diffing from valid object to Date', () => {
738
+ const obj = { a: 1, b: 'hello' }
739
+ const date = new Date('2023-01-01')
740
+
741
+ // @ts-expect-error Testing runtime behavior with non-plain object types
742
+ const result = diff(obj, date)
743
+
744
+ expect(result.changed).toBe(true)
745
+ expect(result.add).toEqual({})
746
+ expect(result.change).toEqual({})
747
+ expect(result.remove).toEqual({ a: 1, b: 'hello' })
748
+ })
749
+
750
+ test('should handle Map objects without throwing TypeError', () => {
751
+ const map = new Map([['key', 'value']])
752
+ const obj = { a: 1 }
753
+
754
+ // @ts-expect-error Testing runtime behavior with non-plain object types
755
+ expect(() => diff(map, obj)).not.toThrow()
756
+ // @ts-expect-error Testing runtime behavior with non-plain object types
757
+ expect(() => diff(obj, map)).not.toThrow()
758
+ // @ts-expect-error Testing runtime behavior with non-plain object types
759
+ expect(() => isEqual(map, obj)).not.toThrow()
760
+ })
761
+
762
+ test('should report additions when diffing from Map to valid object', () => {
763
+ const map = new Map([['key', 'value']])
764
+ const obj = { x: 10, y: 20 }
765
+
766
+ // @ts-expect-error Testing runtime behavior with non-plain object types
767
+ const result = diff(map, obj)
768
+
769
+ expect(result.changed).toBe(true)
770
+ expect(result.add).toEqual({ x: 10, y: 20 })
771
+ expect(result.change).toEqual({})
772
+ expect(result.remove).toEqual({})
773
+ })
774
+
775
+ test('should handle Set objects without throwing TypeError', () => {
776
+ const set = new Set([1, 2, 3])
777
+ const obj = { a: 1 }
778
+
779
+ // @ts-expect-error Testing runtime behavior with non-plain object types
780
+ expect(() => diff(set, obj)).not.toThrow()
781
+ // @ts-expect-error Testing runtime behavior with non-plain object types
782
+ expect(() => diff(obj, set)).not.toThrow()
783
+ // @ts-expect-error Testing runtime behavior with non-plain object types
784
+ expect(() => isEqual(set, obj)).not.toThrow()
785
+ })
786
+
787
+ test('should handle Promise objects without throwing TypeError', () => {
788
+ const promise = Promise.resolve('test')
789
+ const obj = { a: 1 }
790
+
791
+ // @ts-expect-error Testing runtime behavior with non-plain object types
792
+ expect(() => diff(promise, obj)).not.toThrow()
793
+ // @ts-expect-error Testing runtime behavior with non-plain object types
794
+ expect(() => diff(obj, promise)).not.toThrow()
795
+ // @ts-expect-error Testing runtime behavior with non-plain object types
796
+ expect(() => isEqual(promise, obj)).not.toThrow()
797
+ })
798
+
799
+ test('should handle RegExp objects without throwing TypeError', () => {
800
+ const regex = /test/g
801
+ const obj = { a: 1 }
802
+
803
+ // @ts-expect-error Testing runtime behavior with non-plain object types
804
+ expect(() => diff(regex, obj)).not.toThrow()
805
+ // @ts-expect-error Testing runtime behavior with non-plain object types
806
+ expect(() => diff(obj, regex)).not.toThrow()
807
+ // @ts-expect-error Testing runtime behavior with non-plain object types
808
+ expect(() => isEqual(regex, obj)).not.toThrow()
809
+ })
810
+
811
+ test('should handle Function objects without throwing TypeError', () => {
812
+ const func = () => 'test'
813
+ const obj = { a: 1 }
814
+
815
+ // @ts-expect-error Testing runtime behavior with non-plain object types
816
+ expect(() => diff(func, obj)).not.toThrow()
817
+ // @ts-expect-error Testing runtime behavior with non-plain object types
818
+ expect(() => diff(obj, func)).not.toThrow()
819
+ // @ts-expect-error Testing runtime behavior with non-plain object types
820
+ expect(() => isEqual(func, obj)).not.toThrow()
821
+ })
822
+
823
+ test('should handle Error objects without throwing TypeError', () => {
824
+ const error = new Error('test error')
825
+ const obj = { a: 1 }
826
+
827
+ // @ts-expect-error Testing runtime behavior with non-plain object types
828
+ expect(() => diff(error, obj)).not.toThrow()
829
+ // @ts-expect-error Testing runtime behavior with non-plain object types
830
+ expect(() => diff(obj, error)).not.toThrow()
831
+ // @ts-expect-error Testing runtime behavior with non-plain object types
832
+ expect(() => isEqual(error, obj)).not.toThrow()
833
+ })
834
+
835
+ test('should handle WeakMap objects without throwing TypeError', () => {
836
+ const weakMap = new WeakMap()
837
+ const obj = { a: 1 }
838
+
839
+ // @ts-expect-error Testing runtime behavior with non-plain object types
840
+ expect(() => diff(weakMap, obj)).not.toThrow()
841
+ // @ts-expect-error Testing runtime behavior with non-plain object types
842
+ expect(() => diff(obj, weakMap)).not.toThrow()
843
+ // @ts-expect-error Testing runtime behavior with non-plain object types
844
+ expect(() => isEqual(weakMap, obj)).not.toThrow()
845
+ })
846
+
847
+ test('should handle WeakSet objects without throwing TypeError', () => {
848
+ const weakSet = new WeakSet()
849
+ const obj = { a: 1 }
850
+
851
+ // @ts-expect-error Testing runtime behavior with non-plain object types
852
+ expect(() => diff(weakSet, obj)).not.toThrow()
853
+ // @ts-expect-error Testing runtime behavior with non-plain object types
854
+ expect(() => diff(obj, weakSet)).not.toThrow()
855
+ // @ts-expect-error Testing runtime behavior with non-plain object types
856
+ expect(() => isEqual(weakSet, obj)).not.toThrow()
857
+ })
858
+
859
+ test('should handle ArrayBuffer objects without throwing TypeError', () => {
860
+ const buffer = new ArrayBuffer(8)
861
+ const obj = { a: 1 }
862
+
863
+ // @ts-expect-error Testing runtime behavior with non-plain object types
864
+ expect(() => diff(buffer, obj)).not.toThrow()
865
+ // @ts-expect-error Testing runtime behavior with non-plain object types
866
+ expect(() => diff(obj, buffer)).not.toThrow()
867
+ // @ts-expect-error Testing runtime behavior with non-plain object types
868
+ expect(() => isEqual(buffer, obj)).not.toThrow()
869
+ })
870
+
871
+ test('should handle class instances without throwing TypeError', () => {
872
+ class TestClass {
873
+ constructor(public value: string) {}
874
+ }
875
+ const instance = new TestClass('test')
876
+ const obj = { a: 1 }
877
+
878
+ // @ts-expect-error Testing runtime behavior with non-plain object types
879
+ expect(() => diff(instance, obj)).not.toThrow()
880
+ // @ts-expect-error Testing runtime behavior with non-plain object types
881
+ expect(() => diff(obj, instance)).not.toThrow()
882
+ // @ts-expect-error Testing runtime behavior with non-plain object types
883
+ expect(() => isEqual(instance, obj)).not.toThrow()
884
+ })
885
+
886
+ test('should report additions/removals with mixed valid and invalid objects', () => {
887
+ const func = () => 'test'
888
+ const obj1 = { a: 1 }
889
+ const obj2 = { b: 2 }
890
+
891
+ // @ts-expect-error Testing runtime behavior with non-plain object types
892
+ const result1 = diff(func, obj1)
893
+ expect(result1.changed).toBe(true)
894
+ expect(result1.add).toEqual({ a: 1 })
895
+ expect(result1.remove).toEqual({})
896
+
897
+ // @ts-expect-error Testing runtime behavior with non-plain object types
898
+ const result2 = diff(obj2, func)
899
+ expect(result2.changed).toBe(true)
900
+ expect(result2.add).toEqual({})
901
+ expect(result2.remove).toEqual({ b: 2 })
902
+
903
+ // @ts-expect-error Testing runtime behavior with non-plain object types
904
+ const result3 = diff(func, func)
905
+ expect(result3.changed).toBe(false)
906
+ expect(result3.add).toEqual({})
907
+ expect(result3.remove).toEqual({})
908
+ })
909
+ })
910
+ })
911
+
912
+ describe('sparse array handling', () => {
913
+ test('should properly diff sparse array representations', () => {
914
+ // Simulate what happens in store: sparse array [10, 30, 50] with keys ["0", "2", "4"]
915
+ // is represented as a regular array [10, 30, 50] when passed to diff()
916
+ const oldSparseArray: Record<number, number> = [10, 30, 50] // What current() returns for sparse store
917
+ const newDenseArray: Record<number, number> = [100, 200, 300] // What user wants to set
918
+
919
+ const result = diff(oldSparseArray, newDenseArray)
920
+
921
+ // The problem: diff sees this as simple value changes at indices 0, 1, 2
922
+ // But the store actually has sparse keys "0", "2", "4"
923
+ // So when reconcile tries to apply changes, only indices 0 and 2 work
924
+ expect(result.change).toEqual({
925
+ '0': 100, // This works (key "0" exists)
926
+ '1': 200, // This fails (key "1" doesn't exist in sparse structure)
927
+ '2': 300, // This works (key "2" exists)
928
+ })
929
+ expect(result.add).toEqual({})
930
+ expect(result.remove).toEqual({})
931
+ expect(result.changed).toBe(true)
932
+ })
933
+
934
+ test('should handle array-to-object conversion when context suggests sparse structure', () => {
935
+ // This test demonstrates the core issue: we need context about the original structure
936
+ // to properly handle sparse array replacement
937
+ const oldSparseAsObject = { '0': 10, '2': 30, '4': 50 } // Actual sparse structure
938
+ const newDenseArray: Record<number, number> = [100, 200, 300] // User input
939
+
940
+ const result = diff(oldSparseAsObject, newDenseArray)
941
+
942
+ // This should remove old sparse keys and add new dense keys
943
+ expect(result.remove).toEqual({
944
+ '4': UNSET, // Key "4" should be removed (key "2" gets reused)
945
+ })
946
+ expect(result.add).toEqual({
947
+ '1': 200, // Key "1" should be added
948
+ })
949
+ expect(result.change).toEqual({
950
+ '0': 100, // Key "0" changes value from 10 to 100
951
+ '2': 300, // Key "2" changes value from 30 to 300
952
+ })
953
+ expect(result.changed).toBe(true)
954
+ })
638
955
  })
@@ -264,7 +264,7 @@ describe('Effect', () => {
264
264
  errCount++
265
265
  expect(errors[0]).toBeInstanceOf(Error)
266
266
  expect(errors[0].message).toBe(
267
- 'Circular dependency in effect detected',
267
+ 'Circular dependency detected in effect',
268
268
  )
269
269
  },
270
270
  })
@@ -77,13 +77,17 @@ describe('Match Function', () => {
77
77
  expect((errValue as unknown as Error).message).toBe('Test error')
78
78
  })
79
79
 
80
- test('should handle missing handlers gracefully', () => {
80
+ test('should handle missing optional handlers gracefully', () => {
81
81
  const a = state(10)
82
82
  const result = resolve({ a })
83
83
 
84
- // Should not throw even with no handlers
84
+ // Should not throw even with only required ok handler (err and nil are optional)
85
85
  expect(() => {
86
- match(result, {})
86
+ match(result, {
87
+ ok: () => {
88
+ // This handler is required, but err and nil are optional
89
+ },
90
+ })
87
91
  }).not.toThrow()
88
92
  })
89
93
 
@@ -139,6 +143,9 @@ describe('Match Function', () => {
139
143
  let allErrors: readonly Error[] | null = null
140
144
 
141
145
  match(resolve({ a }), {
146
+ ok: () => {
147
+ // This won't be called since there are errors, but it's required
148
+ },
142
149
  err: errors => {
143
150
  // First call with signal error
144
151
  if (errors.length === 1) {
@@ -216,6 +223,9 @@ describe('Match Function', () => {
216
223
  let errorMessages: string[] = []
217
224
 
218
225
  match(resolve({ error1, error2 }), {
226
+ ok: () => {
227
+ // This won't be called since there are errors, but it's required
228
+ },
219
229
  err: errors => {
220
230
  errorMessages = errors.map(e => e.message)
221
231
  },