@zeix/cause-effect 0.17.3 → 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.
- package/.ai-context.md +169 -227
- package/.cursorrules +41 -35
- package/.github/copilot-instructions.md +176 -116
- package/ARCHITECTURE.md +276 -0
- package/CHANGELOG.md +29 -0
- package/CLAUDE.md +201 -143
- package/GUIDE.md +298 -0
- package/README.md +246 -193
- package/REQUIREMENTS.md +100 -0
- package/bench/reactivity.bench.ts +577 -0
- package/context7.json +4 -0
- package/examples/events-sensor.ts +187 -0
- package/examples/selector-sensor.ts +173 -0
- package/index.dev.js +1390 -1008
- package/index.js +1 -1
- package/index.ts +60 -74
- package/package.json +5 -2
- package/skills/changelog-keeper/SKILL.md +59 -0
- package/skills/changelog-keeper/agents/openai.yaml +4 -0
- package/src/errors.ts +118 -74
- package/src/graph.ts +612 -0
- package/src/nodes/collection.ts +512 -0
- package/src/nodes/effect.ts +149 -0
- package/src/nodes/list.ts +589 -0
- package/src/nodes/memo.ts +148 -0
- package/src/nodes/sensor.ts +149 -0
- package/src/nodes/state.ts +135 -0
- package/src/nodes/store.ts +378 -0
- package/src/nodes/task.ts +174 -0
- package/src/signal.ts +112 -66
- package/src/util.ts +26 -57
- package/test/batch.test.ts +96 -62
- package/test/benchmark.test.ts +473 -487
- package/test/collection.test.ts +456 -707
- package/test/effect.test.ts +293 -696
- package/test/list.test.ts +335 -592
- package/test/memo.test.ts +574 -0
- package/test/regression.test.ts +156 -0
- package/test/scope.test.ts +191 -0
- package/test/sensor.test.ts +454 -0
- package/test/signal.test.ts +220 -213
- package/test/state.test.ts +217 -265
- package/test/store.test.ts +346 -446
- package/test/task.test.ts +529 -0
- package/test/untrack.test.ts +167 -0
- package/types/index.d.ts +13 -15
- package/types/src/errors.d.ts +73 -17
- package/types/src/graph.d.ts +218 -0
- package/types/src/nodes/collection.d.ts +69 -0
- package/types/src/nodes/effect.d.ts +48 -0
- package/types/src/nodes/list.d.ts +66 -0
- package/types/src/nodes/memo.d.ts +63 -0
- package/types/src/nodes/sensor.d.ts +81 -0
- package/types/src/nodes/state.d.ts +78 -0
- package/types/src/nodes/store.d.ts +51 -0
- package/types/src/nodes/task.d.ts +79 -0
- package/types/src/signal.d.ts +43 -29
- package/types/src/util.d.ts +9 -16
- package/archive/benchmark.ts +0 -683
- package/archive/collection.ts +0 -253
- package/archive/composite.ts +0 -85
- package/archive/computed.ts +0 -195
- package/archive/list.ts +0 -483
- package/archive/memo.ts +0 -139
- package/archive/state.ts +0 -90
- package/archive/store.ts +0 -298
- package/archive/task.ts +0 -189
- package/src/classes/collection.ts +0 -245
- package/src/classes/computed.ts +0 -349
- package/src/classes/list.ts +0 -343
- package/src/classes/ref.ts +0 -70
- package/src/classes/state.ts +0 -102
- package/src/classes/store.ts +0 -262
- package/src/diff.ts +0 -138
- package/src/effect.ts +0 -93
- package/src/match.ts +0 -45
- package/src/resolve.ts +0 -49
- package/src/system.ts +0 -257
- package/test/computed.test.ts +0 -1108
- package/test/diff.test.ts +0 -955
- package/test/match.test.ts +0 -388
- package/test/ref.test.ts +0 -353
- package/test/resolve.test.ts +0 -154
- package/types/src/classes/collection.d.ts +0 -45
- package/types/src/classes/computed.d.ts +0 -94
- package/types/src/classes/list.d.ts +0 -43
- package/types/src/classes/ref.d.ts +0 -35
- package/types/src/classes/state.d.ts +0 -49
- package/types/src/classes/store.d.ts +0 -52
- package/types/src/diff.d.ts +0 -28
- package/types/src/effect.d.ts +0 -15
- package/types/src/match.d.ts +0 -21
- package/types/src/resolve.d.ts +0 -29
- package/types/src/system.d.ts +0 -78
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { createEffect, createScope, createState } from '../index.ts'
|
|
3
|
+
|
|
4
|
+
/* === Tests === */
|
|
5
|
+
|
|
6
|
+
describe('createScope', () => {
|
|
7
|
+
test('should return a dispose function', () => {
|
|
8
|
+
const dispose = createScope(() => {})
|
|
9
|
+
expect(typeof dispose).toBe('function')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('should run the callback immediately', () => {
|
|
13
|
+
let ran = false
|
|
14
|
+
createScope(() => {
|
|
15
|
+
ran = true
|
|
16
|
+
})
|
|
17
|
+
expect(ran).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('should call returned cleanup on dispose', () => {
|
|
21
|
+
let cleaned = false
|
|
22
|
+
const dispose = createScope(() => {
|
|
23
|
+
return () => {
|
|
24
|
+
cleaned = true
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
expect(cleaned).toBe(false)
|
|
28
|
+
dispose()
|
|
29
|
+
expect(cleaned).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('should dispose child effects', () => {
|
|
33
|
+
const source = createState(0)
|
|
34
|
+
let count = 0
|
|
35
|
+
const dispose = createScope(() => {
|
|
36
|
+
createEffect((): undefined => {
|
|
37
|
+
source.get()
|
|
38
|
+
count++
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
expect(count).toBe(1)
|
|
42
|
+
source.set(1)
|
|
43
|
+
expect(count).toBe(2)
|
|
44
|
+
dispose()
|
|
45
|
+
source.set(2)
|
|
46
|
+
expect(count).toBe(2) // effect should no longer run
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('should dispose multiple child effects', () => {
|
|
50
|
+
const a = createState(0)
|
|
51
|
+
const b = createState(0)
|
|
52
|
+
let countA = 0
|
|
53
|
+
let countB = 0
|
|
54
|
+
const dispose = createScope(() => {
|
|
55
|
+
createEffect((): undefined => {
|
|
56
|
+
a.get()
|
|
57
|
+
countA++
|
|
58
|
+
})
|
|
59
|
+
createEffect((): undefined => {
|
|
60
|
+
b.get()
|
|
61
|
+
countB++
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
expect(countA).toBe(1)
|
|
65
|
+
expect(countB).toBe(1)
|
|
66
|
+
dispose()
|
|
67
|
+
a.set(1)
|
|
68
|
+
b.set(1)
|
|
69
|
+
expect(countA).toBe(1)
|
|
70
|
+
expect(countB).toBe(1)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('should call returned cleanup and dispose child effects', () => {
|
|
74
|
+
const source = createState(0)
|
|
75
|
+
let effectCount = 0
|
|
76
|
+
let cleaned = false
|
|
77
|
+
const dispose = createScope(() => {
|
|
78
|
+
createEffect((): undefined => {
|
|
79
|
+
source.get()
|
|
80
|
+
effectCount++
|
|
81
|
+
})
|
|
82
|
+
return () => {
|
|
83
|
+
cleaned = true
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
expect(effectCount).toBe(1)
|
|
87
|
+
expect(cleaned).toBe(false)
|
|
88
|
+
dispose()
|
|
89
|
+
expect(cleaned).toBe(true)
|
|
90
|
+
source.set(1)
|
|
91
|
+
expect(effectCount).toBe(1)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('should handle nested scopes independently', () => {
|
|
95
|
+
const source = createState(0)
|
|
96
|
+
let outerCount = 0
|
|
97
|
+
let innerCount = 0
|
|
98
|
+
let innerDispose!: () => void
|
|
99
|
+
const outerDispose = createScope(() => {
|
|
100
|
+
createEffect((): undefined => {
|
|
101
|
+
source.get()
|
|
102
|
+
outerCount++
|
|
103
|
+
})
|
|
104
|
+
innerDispose = createScope(() => {
|
|
105
|
+
createEffect((): undefined => {
|
|
106
|
+
source.get()
|
|
107
|
+
innerCount++
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
expect(outerCount).toBe(1)
|
|
112
|
+
expect(innerCount).toBe(1)
|
|
113
|
+
source.set(1)
|
|
114
|
+
expect(outerCount).toBe(2)
|
|
115
|
+
expect(innerCount).toBe(2)
|
|
116
|
+
|
|
117
|
+
// disposing inner scope should not affect outer
|
|
118
|
+
innerDispose()
|
|
119
|
+
source.set(2)
|
|
120
|
+
expect(outerCount).toBe(3)
|
|
121
|
+
expect(innerCount).toBe(2)
|
|
122
|
+
|
|
123
|
+
// disposing outer scope should have no further effect
|
|
124
|
+
outerDispose()
|
|
125
|
+
source.set(3)
|
|
126
|
+
expect(outerCount).toBe(3)
|
|
127
|
+
expect(innerCount).toBe(2)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('should dispose nested scopes when parent is disposed', () => {
|
|
131
|
+
const source = createState(0)
|
|
132
|
+
let innerCount = 0
|
|
133
|
+
const outerDispose = createScope(() => {
|
|
134
|
+
createScope(() => {
|
|
135
|
+
createEffect((): undefined => {
|
|
136
|
+
source.get()
|
|
137
|
+
innerCount++
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
expect(innerCount).toBe(1)
|
|
142
|
+
source.set(1)
|
|
143
|
+
expect(innerCount).toBe(2)
|
|
144
|
+
|
|
145
|
+
// disposing outer should also dispose inner
|
|
146
|
+
outerDispose()
|
|
147
|
+
source.set(2)
|
|
148
|
+
expect(innerCount).toBe(2)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('should call nested cleanup functions on parent dispose', () => {
|
|
152
|
+
let outerCleaned = false
|
|
153
|
+
let innerCleaned = false
|
|
154
|
+
const dispose = createScope(() => {
|
|
155
|
+
createScope(() => {
|
|
156
|
+
return () => {
|
|
157
|
+
innerCleaned = true
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
return () => {
|
|
161
|
+
outerCleaned = true
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
expect(outerCleaned).toBe(false)
|
|
165
|
+
expect(innerCleaned).toBe(false)
|
|
166
|
+
dispose()
|
|
167
|
+
expect(outerCleaned).toBe(true)
|
|
168
|
+
expect(innerCleaned).toBe(true)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('should be safe to call dispose multiple times', () => {
|
|
172
|
+
let cleanCount = 0
|
|
173
|
+
const dispose = createScope(() => {
|
|
174
|
+
return () => {
|
|
175
|
+
cleanCount++
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
dispose()
|
|
179
|
+
expect(cleanCount).toBe(1)
|
|
180
|
+
dispose()
|
|
181
|
+
// cleanup should only run once since it's nulled after first run
|
|
182
|
+
expect(cleanCount).toBe(1)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('should handle scope with no cleanup return', () => {
|
|
186
|
+
const dispose = createScope(() => {
|
|
187
|
+
// no return
|
|
188
|
+
})
|
|
189
|
+
expect(() => dispose()).not.toThrow()
|
|
190
|
+
})
|
|
191
|
+
})
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
createEffect,
|
|
4
|
+
createMemo,
|
|
5
|
+
createSensor,
|
|
6
|
+
isMemo,
|
|
7
|
+
isSensor,
|
|
8
|
+
SKIP_EQUALITY,
|
|
9
|
+
UnsetSignalValueError,
|
|
10
|
+
} from '../index.ts'
|
|
11
|
+
|
|
12
|
+
/* === Utility Functions === */
|
|
13
|
+
|
|
14
|
+
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
|
15
|
+
|
|
16
|
+
/* === Tests === */
|
|
17
|
+
|
|
18
|
+
describe('Sensor', () => {
|
|
19
|
+
describe('createSensor', () => {
|
|
20
|
+
test('should have Symbol.toStringTag of "Sensor"', () => {
|
|
21
|
+
const sensor = createSensor<number>(() => () => {})
|
|
22
|
+
expect(sensor[Symbol.toStringTag]).toBe('Sensor')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('should throw UnsetSignalValueError when read outside an effect', () => {
|
|
26
|
+
const sensor = createSensor<number>(set => {
|
|
27
|
+
set(42)
|
|
28
|
+
return () => {}
|
|
29
|
+
})
|
|
30
|
+
expect(() => sensor.get()).toThrow(UnsetSignalValueError)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('should activate and return value when read inside an effect', () => {
|
|
34
|
+
let started = false
|
|
35
|
+
const sensor = createSensor<number>(set => {
|
|
36
|
+
started = true
|
|
37
|
+
set(42)
|
|
38
|
+
return () => {}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
expect(started).toBe(false)
|
|
42
|
+
|
|
43
|
+
let received: number | undefined
|
|
44
|
+
createEffect(() => {
|
|
45
|
+
received = sensor.get()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
expect(started).toBe(true)
|
|
49
|
+
expect(received).toBe(42)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('isSensor', () => {
|
|
54
|
+
test('should identify sensor signals', () => {
|
|
55
|
+
expect(isSensor(createSensor<number>(() => () => {}))).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('should return false for non-sensor values', () => {
|
|
59
|
+
expect(isSensor(42)).toBe(false)
|
|
60
|
+
expect(isSensor(null)).toBe(false)
|
|
61
|
+
expect(isSensor({})).toBe(false)
|
|
62
|
+
expect(isMemo(createSensor<number>(() => () => {}))).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('start/link ordering', () => {
|
|
67
|
+
test('synchronous set() inside start should be visible to activating effect', () => {
|
|
68
|
+
// Start fires before link: synchronous set() updates node.value
|
|
69
|
+
// without propagation (no sinks yet). The activating effect reads
|
|
70
|
+
// the updated value directly after link completes.
|
|
71
|
+
const sensor = createSensor<number>(
|
|
72
|
+
set => {
|
|
73
|
+
set(10)
|
|
74
|
+
return () => {}
|
|
75
|
+
},
|
|
76
|
+
{ value: 0 },
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const doubled = createMemo(() => sensor.get() * 2)
|
|
80
|
+
|
|
81
|
+
let result = 0
|
|
82
|
+
createEffect(() => {
|
|
83
|
+
result = doubled.get()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// The memo should see 10 (from start's set), not 0 (initial value)
|
|
87
|
+
expect(result).toBe(20)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('set callback', () => {
|
|
92
|
+
test('should update value and trigger effects', () => {
|
|
93
|
+
let setFn!: (v: number) => void
|
|
94
|
+
const sensor = createSensor<number>(set => {
|
|
95
|
+
setFn = set
|
|
96
|
+
set(0)
|
|
97
|
+
return () => {}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
let effectCount = 0
|
|
101
|
+
let received = 0
|
|
102
|
+
createEffect(() => {
|
|
103
|
+
received = sensor.get()
|
|
104
|
+
effectCount++
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
expect(received).toBe(0)
|
|
108
|
+
expect(effectCount).toBe(1)
|
|
109
|
+
|
|
110
|
+
setFn(10)
|
|
111
|
+
expect(received).toBe(10)
|
|
112
|
+
expect(effectCount).toBe(2)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('should notify multiple effects', () => {
|
|
116
|
+
let setFn!: (v: string) => void
|
|
117
|
+
const sensor = createSensor<string>(set => {
|
|
118
|
+
setFn = set
|
|
119
|
+
set('initial')
|
|
120
|
+
return () => {}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const mock1 = mock(() => {})
|
|
124
|
+
const mock2 = mock(() => {})
|
|
125
|
+
|
|
126
|
+
createEffect(() => {
|
|
127
|
+
sensor.get()
|
|
128
|
+
mock1()
|
|
129
|
+
})
|
|
130
|
+
createEffect(() => {
|
|
131
|
+
sensor.get()
|
|
132
|
+
mock2()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
expect(mock1).toHaveBeenCalledTimes(1)
|
|
136
|
+
expect(mock2).toHaveBeenCalledTimes(1)
|
|
137
|
+
|
|
138
|
+
setFn('updated')
|
|
139
|
+
expect(mock1).toHaveBeenCalledTimes(2)
|
|
140
|
+
expect(mock2).toHaveBeenCalledTimes(2)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('Lazy Activation', () => {
|
|
145
|
+
test('should only call start when first effect subscribes', async () => {
|
|
146
|
+
let counter = 0
|
|
147
|
+
let intervalId: Timer | undefined
|
|
148
|
+
|
|
149
|
+
const sensor = createSensor<number>(set => {
|
|
150
|
+
set(0)
|
|
151
|
+
intervalId = setInterval(() => {
|
|
152
|
+
counter++
|
|
153
|
+
set(counter)
|
|
154
|
+
}, 10)
|
|
155
|
+
return () => {
|
|
156
|
+
clearInterval(intervalId)
|
|
157
|
+
intervalId = undefined
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
expect(counter).toBe(0)
|
|
162
|
+
await wait(50)
|
|
163
|
+
expect(counter).toBe(0)
|
|
164
|
+
expect(intervalId).toBeUndefined()
|
|
165
|
+
|
|
166
|
+
const dispose = createEffect(() => {
|
|
167
|
+
sensor.get()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
await wait(50)
|
|
171
|
+
expect(counter).toBeGreaterThan(0)
|
|
172
|
+
expect(intervalId).toBeDefined()
|
|
173
|
+
|
|
174
|
+
dispose()
|
|
175
|
+
const counterAfterStop = counter
|
|
176
|
+
|
|
177
|
+
await wait(50)
|
|
178
|
+
expect(counter).toBe(counterAfterStop)
|
|
179
|
+
expect(intervalId).toBeUndefined()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('should call start only once for multiple subscribers', () => {
|
|
183
|
+
let startCount = 0
|
|
184
|
+
let cleanupCount = 0
|
|
185
|
+
|
|
186
|
+
const sensor = createSensor<number>(set => {
|
|
187
|
+
startCount++
|
|
188
|
+
set(1)
|
|
189
|
+
return () => {
|
|
190
|
+
cleanupCount++
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
const dispose1 = createEffect(() => {
|
|
195
|
+
sensor.get()
|
|
196
|
+
})
|
|
197
|
+
expect(startCount).toBe(1)
|
|
198
|
+
|
|
199
|
+
const dispose2 = createEffect(() => {
|
|
200
|
+
sensor.get()
|
|
201
|
+
})
|
|
202
|
+
expect(startCount).toBe(1)
|
|
203
|
+
|
|
204
|
+
dispose1()
|
|
205
|
+
expect(cleanupCount).toBe(0) // still has subscriber
|
|
206
|
+
|
|
207
|
+
dispose2()
|
|
208
|
+
expect(cleanupCount).toBe(1)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('should reactivate after all subscribers leave and new one arrives', () => {
|
|
212
|
+
let startCount = 0
|
|
213
|
+
let cleanupCount = 0
|
|
214
|
+
|
|
215
|
+
const sensor = createSensor<number>(set => {
|
|
216
|
+
startCount++
|
|
217
|
+
set(startCount)
|
|
218
|
+
return () => {
|
|
219
|
+
cleanupCount++
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
const dispose1 = createEffect(() => {
|
|
224
|
+
sensor.get()
|
|
225
|
+
})
|
|
226
|
+
expect(startCount).toBe(1)
|
|
227
|
+
|
|
228
|
+
dispose1()
|
|
229
|
+
expect(cleanupCount).toBe(1)
|
|
230
|
+
|
|
231
|
+
let received = 0
|
|
232
|
+
const dispose2 = createEffect(() => {
|
|
233
|
+
received = sensor.get()
|
|
234
|
+
})
|
|
235
|
+
expect(startCount).toBe(2)
|
|
236
|
+
expect(received).toBe(2)
|
|
237
|
+
|
|
238
|
+
dispose2()
|
|
239
|
+
expect(cleanupCount).toBe(2)
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
describe('options.equals', () => {
|
|
244
|
+
test('should skip update when value is equal by default', () => {
|
|
245
|
+
let setFn!: (v: number) => void
|
|
246
|
+
const sensor = createSensor<number>(set => {
|
|
247
|
+
setFn = set
|
|
248
|
+
set(5)
|
|
249
|
+
return () => {}
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
let effectCount = 0
|
|
253
|
+
createEffect(() => {
|
|
254
|
+
sensor.get()
|
|
255
|
+
effectCount++
|
|
256
|
+
})
|
|
257
|
+
expect(effectCount).toBe(1)
|
|
258
|
+
|
|
259
|
+
setFn(5) // same value
|
|
260
|
+
expect(effectCount).toBe(1)
|
|
261
|
+
|
|
262
|
+
setFn(6)
|
|
263
|
+
expect(effectCount).toBe(2)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test('should use custom equality function', () => {
|
|
267
|
+
let setFn!: (v: { x: number }) => void
|
|
268
|
+
const sensor = createSensor<{ x: number }>(
|
|
269
|
+
set => {
|
|
270
|
+
setFn = set
|
|
271
|
+
set({ x: 1 })
|
|
272
|
+
return () => {}
|
|
273
|
+
},
|
|
274
|
+
{ equals: (a, b) => a?.x === b?.x },
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
let effectCount = 0
|
|
278
|
+
createEffect(() => {
|
|
279
|
+
sensor.get()
|
|
280
|
+
effectCount++
|
|
281
|
+
})
|
|
282
|
+
expect(effectCount).toBe(1)
|
|
283
|
+
|
|
284
|
+
setFn({ x: 1 }) // structurally equal
|
|
285
|
+
expect(effectCount).toBe(1)
|
|
286
|
+
|
|
287
|
+
setFn({ x: 2 })
|
|
288
|
+
expect(effectCount).toBe(2)
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
describe('options.guard', () => {
|
|
293
|
+
test('should validate values from set callback', () => {
|
|
294
|
+
let setFn!: (v: number) => void
|
|
295
|
+
const sensor = createSensor<number>(
|
|
296
|
+
set => {
|
|
297
|
+
setFn = set
|
|
298
|
+
set(1)
|
|
299
|
+
return () => {}
|
|
300
|
+
},
|
|
301
|
+
{ guard: (v): v is number => typeof v === 'number' && v > 0 },
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
createEffect(() => {
|
|
305
|
+
sensor.get()
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
expect(() => setFn(5)).not.toThrow()
|
|
309
|
+
expect(() => setFn(-1)).toThrow()
|
|
310
|
+
expect(() => setFn(0)).toThrow()
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
describe('options.value', () => {
|
|
315
|
+
test('should use initial value before activation', () => {
|
|
316
|
+
const sensor = createSensor<number>(() => () => {}, { value: 99 })
|
|
317
|
+
|
|
318
|
+
let received: number | undefined
|
|
319
|
+
createEffect(() => {
|
|
320
|
+
received = sensor.get()
|
|
321
|
+
})
|
|
322
|
+
expect(received).toBe(99)
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
describe('SKIP_EQUALITY', () => {
|
|
327
|
+
test('should always return false', () => {
|
|
328
|
+
expect(SKIP_EQUALITY()).toBe(false)
|
|
329
|
+
expect(SKIP_EQUALITY(1, 1)).toBe(false)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
test('should return the reference value from get()', () => {
|
|
333
|
+
const obj = { name: 'test' }
|
|
334
|
+
const sensor = createSensor<typeof obj>(
|
|
335
|
+
set => {
|
|
336
|
+
set(obj)
|
|
337
|
+
return () => {}
|
|
338
|
+
},
|
|
339
|
+
{ value: obj, equals: SKIP_EQUALITY },
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
let received: typeof obj | undefined
|
|
343
|
+
const dispose = createEffect(() => {
|
|
344
|
+
received = sensor.get()
|
|
345
|
+
})
|
|
346
|
+
expect(received).toBe(obj)
|
|
347
|
+
dispose()
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
test('should re-run effects when set is called with same reference', () => {
|
|
351
|
+
const obj = { status: 'offline' }
|
|
352
|
+
let setFn!: (next: typeof obj) => void
|
|
353
|
+
|
|
354
|
+
const sensor = createSensor<typeof obj>(
|
|
355
|
+
set => {
|
|
356
|
+
setFn = set
|
|
357
|
+
set(obj)
|
|
358
|
+
return () => {}
|
|
359
|
+
},
|
|
360
|
+
{ equals: SKIP_EQUALITY },
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
let effectCount = 0
|
|
364
|
+
let lastStatus = ''
|
|
365
|
+
createEffect(() => {
|
|
366
|
+
lastStatus = sensor.get().status
|
|
367
|
+
effectCount++
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
expect(effectCount).toBe(1)
|
|
371
|
+
expect(lastStatus).toBe('offline')
|
|
372
|
+
|
|
373
|
+
obj.status = 'online'
|
|
374
|
+
expect(effectCount).toBe(1) // no set yet
|
|
375
|
+
|
|
376
|
+
setFn(obj) // same reference, but SKIP_EQUALITY ensures propagation
|
|
377
|
+
expect(effectCount).toBe(2)
|
|
378
|
+
expect(lastStatus).toBe('online')
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
test('should trigger multiple effect runs on multiple set calls', () => {
|
|
382
|
+
const obj = { size: 100 }
|
|
383
|
+
let setFn!: (next: typeof obj) => void
|
|
384
|
+
|
|
385
|
+
const sensor = createSensor<typeof obj>(
|
|
386
|
+
set => {
|
|
387
|
+
setFn = set
|
|
388
|
+
set(obj)
|
|
389
|
+
return () => {}
|
|
390
|
+
},
|
|
391
|
+
{ equals: SKIP_EQUALITY },
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
const callback = mock(() => {})
|
|
395
|
+
createEffect(() => {
|
|
396
|
+
sensor.get()
|
|
397
|
+
callback()
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
expect(callback).toHaveBeenCalledTimes(1)
|
|
401
|
+
|
|
402
|
+
setFn(obj)
|
|
403
|
+
expect(callback).toHaveBeenCalledTimes(2)
|
|
404
|
+
|
|
405
|
+
setFn(obj)
|
|
406
|
+
expect(callback).toHaveBeenCalledTimes(3)
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
test('should validate values passed through set()', () => {
|
|
410
|
+
let setFn!: (next: unknown) => void
|
|
411
|
+
|
|
412
|
+
const sensor = createSensor<{ x: number }>(
|
|
413
|
+
set => {
|
|
414
|
+
setFn = set as (next: unknown) => void
|
|
415
|
+
set({ x: 1 })
|
|
416
|
+
return () => {}
|
|
417
|
+
},
|
|
418
|
+
{ equals: SKIP_EQUALITY },
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
createEffect(() => {
|
|
422
|
+
sensor.get()
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
expect(() => {
|
|
426
|
+
// biome-ignore lint/suspicious/noExplicitAny: testing invalid input
|
|
427
|
+
setFn(null as any)
|
|
428
|
+
}).toThrow('[Sensor] Signal value cannot be null or undefined')
|
|
429
|
+
})
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
describe('Input Validation', () => {
|
|
433
|
+
test('should throw InvalidCallbackError for non-function start', () => {
|
|
434
|
+
expect(() => {
|
|
435
|
+
// @ts-expect-error - Testing invalid input
|
|
436
|
+
createSensor(null)
|
|
437
|
+
}).toThrow('[Sensor] Callback null is invalid')
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
test('should throw InvalidCallbackError for async start callback', () => {
|
|
441
|
+
expect(() => {
|
|
442
|
+
// @ts-expect-error - Testing invalid input
|
|
443
|
+
createSensor(async () => () => {})
|
|
444
|
+
}).toThrow()
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test('should throw NullishSignalValueError for null initial value', () => {
|
|
448
|
+
expect(() => {
|
|
449
|
+
// @ts-expect-error - Testing invalid input
|
|
450
|
+
createSensor<number>(() => () => {}, { value: null })
|
|
451
|
+
}).toThrow('[Sensor] Signal value cannot be null or undefined')
|
|
452
|
+
})
|
|
453
|
+
})
|
|
454
|
+
})
|