@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
package/test/store.test.ts
CHANGED
|
@@ -1,51 +1,81 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
import {
|
|
3
|
-
BaseStore,
|
|
4
3
|
createEffect,
|
|
4
|
+
createMemo,
|
|
5
|
+
createState,
|
|
5
6
|
createStore,
|
|
7
|
+
isList,
|
|
6
8
|
isStore,
|
|
7
|
-
Memo,
|
|
8
|
-
State,
|
|
9
|
-
UNSET,
|
|
10
9
|
} from '../index.ts'
|
|
11
10
|
|
|
12
|
-
describe('
|
|
13
|
-
describe('
|
|
14
|
-
test('
|
|
15
|
-
const user =
|
|
11
|
+
describe('Store', () => {
|
|
12
|
+
describe('createStore', () => {
|
|
13
|
+
test('should create a store with initial values', () => {
|
|
14
|
+
const user = createStore({
|
|
16
15
|
name: 'Hannah',
|
|
17
16
|
email: 'hannah@example.com',
|
|
18
17
|
})
|
|
19
|
-
expect(user.
|
|
20
|
-
expect(user.
|
|
18
|
+
expect(user.name.get()).toBe('Hannah')
|
|
19
|
+
expect(user.email.get()).toBe('hannah@example.com')
|
|
21
20
|
})
|
|
22
21
|
|
|
23
|
-
test('
|
|
22
|
+
test('should create nested stores for object properties', () => {
|
|
24
23
|
const user = createStore({
|
|
25
|
-
name: '
|
|
26
|
-
|
|
24
|
+
name: 'Alice',
|
|
25
|
+
preferences: { theme: 'light', notifications: true },
|
|
27
26
|
})
|
|
28
|
-
expect(user.
|
|
29
|
-
expect(user.
|
|
27
|
+
expect(isStore(user.preferences)).toBe(true)
|
|
28
|
+
expect(user.preferences.theme.get()).toBe('light')
|
|
29
|
+
expect(user.preferences.notifications.get()).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('should create lists for array properties', () => {
|
|
33
|
+
const data = createStore({ tags: ['a', 'b', 'c'] })
|
|
34
|
+
expect(isList(data.tags)).toBe(true)
|
|
35
|
+
expect(data.tags.get()).toEqual(['a', 'b', 'c'])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('should handle deeply nested objects', () => {
|
|
39
|
+
const config = createStore({
|
|
40
|
+
ui: { theme: { colors: { primary: '#007acc' } } },
|
|
41
|
+
})
|
|
42
|
+
expect(config.ui.theme.colors.primary.get()).toBe('#007acc')
|
|
43
|
+
config.ui.theme.colors.primary.set('#ff6600')
|
|
44
|
+
expect(config.ui.theme.colors.primary.get()).toBe('#ff6600')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('should handle empty initial value', () => {
|
|
48
|
+
const empty = createStore({})
|
|
49
|
+
expect(empty.get()).toEqual({})
|
|
30
50
|
})
|
|
31
51
|
|
|
32
|
-
test('
|
|
52
|
+
test('should have Symbol.toStringTag of "Store"', () => {
|
|
33
53
|
const store = createStore({ a: 1 })
|
|
34
54
|
expect(store[Symbol.toStringTag]).toBe('Store')
|
|
35
55
|
})
|
|
36
56
|
|
|
37
|
-
test('
|
|
57
|
+
test('should have Symbol.isConcatSpreadable set to false', () => {
|
|
38
58
|
const store = createStore({ a: 1 })
|
|
39
|
-
|
|
40
|
-
|
|
59
|
+
expect(store[Symbol.isConcatSpreadable]).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
41
62
|
|
|
63
|
+
describe('isStore', () => {
|
|
64
|
+
test('should return true for store instances', () => {
|
|
65
|
+
const store = createStore({ a: 1 })
|
|
42
66
|
expect(isStore(store)).toBe(true)
|
|
43
|
-
|
|
44
|
-
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('should return false for non-store values', () => {
|
|
70
|
+
expect(isStore(createState(1))).toBe(false)
|
|
71
|
+
expect(isStore(createMemo(() => 1))).toBe(false)
|
|
45
72
|
expect(isStore({})).toBe(false)
|
|
73
|
+
expect(isStore(null)).toBe(false)
|
|
46
74
|
})
|
|
75
|
+
})
|
|
47
76
|
|
|
48
|
-
|
|
77
|
+
describe('get', () => {
|
|
78
|
+
test('should return the complete store value', () => {
|
|
49
79
|
const user = createStore({
|
|
50
80
|
name: 'Alice',
|
|
51
81
|
email: 'alice@example.com',
|
|
@@ -55,146 +85,240 @@ describe('store', () => {
|
|
|
55
85
|
email: 'alice@example.com',
|
|
56
86
|
})
|
|
57
87
|
})
|
|
88
|
+
|
|
89
|
+
test('should return updated value after property changes', () => {
|
|
90
|
+
const user = createStore({ name: 'Alice', age: 30 })
|
|
91
|
+
user.name.set('Bob')
|
|
92
|
+
expect(user.get()).toEqual({ name: 'Bob', age: 30 })
|
|
93
|
+
})
|
|
58
94
|
})
|
|
59
95
|
|
|
60
|
-
describe('
|
|
61
|
-
test('
|
|
62
|
-
const user = createStore({
|
|
63
|
-
|
|
64
|
-
|
|
96
|
+
describe('set', () => {
|
|
97
|
+
test('should replace entire store value', () => {
|
|
98
|
+
const user = createStore({
|
|
99
|
+
name: 'John',
|
|
100
|
+
email: 'john@example.com',
|
|
101
|
+
})
|
|
102
|
+
user.set({ name: 'Jane', email: 'jane@example.com' })
|
|
103
|
+
expect(user.name.get()).toBe('Jane')
|
|
104
|
+
expect(user.email.get()).toBe('jane@example.com')
|
|
105
|
+
})
|
|
65
106
|
|
|
66
|
-
|
|
67
|
-
user
|
|
68
|
-
|
|
69
|
-
|
|
107
|
+
test('should diff and apply granular changes', () => {
|
|
108
|
+
const user = createStore({ name: 'John', age: 25 })
|
|
109
|
+
let nameRuns = 0
|
|
110
|
+
let ageRuns = 0
|
|
111
|
+
createEffect(() => {
|
|
112
|
+
user.name.get()
|
|
113
|
+
nameRuns++
|
|
114
|
+
})
|
|
115
|
+
createEffect(() => {
|
|
116
|
+
user.age.get()
|
|
117
|
+
ageRuns++
|
|
118
|
+
})
|
|
119
|
+
expect(nameRuns).toBe(1)
|
|
120
|
+
expect(ageRuns).toBe(1)
|
|
121
|
+
|
|
122
|
+
// Only change age — name effect should not re-run
|
|
123
|
+
user.set({ name: 'John', age: 26 })
|
|
124
|
+
expect(nameRuns).toBe(1)
|
|
125
|
+
expect(ageRuns).toBe(2)
|
|
70
126
|
})
|
|
71
127
|
|
|
72
|
-
test('
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
128
|
+
test('should not propagate when value is identical', () => {
|
|
129
|
+
const store = createStore({ x: 1 })
|
|
130
|
+
let runs = 0
|
|
131
|
+
createEffect(() => {
|
|
132
|
+
store.get()
|
|
133
|
+
runs++
|
|
134
|
+
})
|
|
135
|
+
expect(runs).toBe(1)
|
|
136
|
+
|
|
137
|
+
store.set({ x: 1 })
|
|
138
|
+
expect(runs).toBe(1)
|
|
76
139
|
})
|
|
140
|
+
})
|
|
77
141
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
142
|
+
describe('update', () => {
|
|
143
|
+
test('should modify store using callback', () => {
|
|
144
|
+
const user = createStore({ name: 'John', age: 25 })
|
|
145
|
+
user.update(u => ({ ...u, age: u.age + 1 }))
|
|
146
|
+
expect(user.name.get()).toBe('John')
|
|
147
|
+
expect(user.age.get()).toBe(26)
|
|
82
148
|
})
|
|
83
149
|
})
|
|
84
150
|
|
|
85
|
-
describe('add
|
|
86
|
-
test('add
|
|
151
|
+
describe('add', () => {
|
|
152
|
+
test('should add a new property', () => {
|
|
87
153
|
const user = createStore<{ name: string; email?: string }>({
|
|
88
154
|
name: 'John',
|
|
89
155
|
})
|
|
90
156
|
user.add('email', 'john@example.com')
|
|
91
|
-
expect(user.byKey('email')?.get()).toBe('john@example.com')
|
|
92
157
|
expect(user.email?.get()).toBe('john@example.com')
|
|
158
|
+
expect(user.byKey('email')?.get()).toBe('john@example.com')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('should throw DuplicateKeyError for existing key', () => {
|
|
162
|
+
const user = createStore({ name: 'John' })
|
|
163
|
+
expect(() => user.add('name', 'Jane')).toThrow()
|
|
93
164
|
})
|
|
94
165
|
|
|
95
|
-
test('
|
|
166
|
+
test('should be reactive', () => {
|
|
167
|
+
const store = createStore<{ name: string; email?: string }>({
|
|
168
|
+
name: 'John',
|
|
169
|
+
})
|
|
170
|
+
let lastValue: { name: string; email?: string } = { name: '' }
|
|
171
|
+
createEffect(() => {
|
|
172
|
+
lastValue = store.get()
|
|
173
|
+
})
|
|
174
|
+
expect(lastValue).toEqual({ name: 'John' })
|
|
175
|
+
|
|
176
|
+
store.add('email', 'john@example.com')
|
|
177
|
+
expect(lastValue).toEqual({
|
|
178
|
+
name: 'John',
|
|
179
|
+
email: 'john@example.com',
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('remove', () => {
|
|
185
|
+
test('should remove an existing property', () => {
|
|
96
186
|
const user = createStore<{ name: string; email?: string }>({
|
|
97
187
|
name: 'John',
|
|
98
188
|
email: 'john@example.com',
|
|
99
189
|
})
|
|
100
190
|
user.remove('email')
|
|
101
191
|
expect(user.byKey('email')).toBeUndefined()
|
|
102
|
-
// expect(user.byKey('name').get()).toBe('John')
|
|
103
192
|
expect(user.email).toBeUndefined()
|
|
104
|
-
// expect(user.name.get()).toBe('John')
|
|
105
193
|
})
|
|
106
194
|
|
|
107
|
-
test('
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
})
|
|
111
|
-
// @ts-expect-error testing null values
|
|
112
|
-
expect(() => user.add('email', null)).toThrow()
|
|
195
|
+
test('should handle non-existent key gracefully', () => {
|
|
196
|
+
const store = createStore({ name: 'John' })
|
|
197
|
+
expect(() => store.remove('nonexistent')).not.toThrow()
|
|
113
198
|
})
|
|
114
199
|
|
|
115
|
-
test('
|
|
116
|
-
const
|
|
200
|
+
test('should be reactive', () => {
|
|
201
|
+
const store = createStore({
|
|
117
202
|
name: 'John',
|
|
118
203
|
email: 'john@example.com',
|
|
119
204
|
})
|
|
120
|
-
|
|
121
|
-
|
|
205
|
+
let lastValue: { name: string; email?: string } = {
|
|
206
|
+
name: '',
|
|
207
|
+
email: '',
|
|
208
|
+
}
|
|
209
|
+
let runs = 0
|
|
210
|
+
createEffect(() => {
|
|
211
|
+
lastValue = store.get()
|
|
212
|
+
runs++
|
|
213
|
+
})
|
|
214
|
+
expect(runs).toBe(1)
|
|
122
215
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
expect(
|
|
216
|
+
store.remove('email')
|
|
217
|
+
expect(lastValue).toEqual({ name: 'John' })
|
|
218
|
+
expect(runs).toBe(2)
|
|
126
219
|
})
|
|
127
220
|
})
|
|
128
221
|
|
|
129
|
-
describe('
|
|
130
|
-
test('
|
|
131
|
-
const user = createStore({
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
},
|
|
137
|
-
})
|
|
222
|
+
describe('byKey', () => {
|
|
223
|
+
test('should return the signal for a property', () => {
|
|
224
|
+
const user = createStore({ name: 'Alice', age: 30 })
|
|
225
|
+
const nameSignal = user.byKey('name')
|
|
226
|
+
expect(nameSignal?.get()).toBe('Alice')
|
|
227
|
+
expect(nameSignal).toBe(user.name)
|
|
228
|
+
})
|
|
138
229
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
230
|
+
test('should return nested store for object property', () => {
|
|
231
|
+
const app = createStore({ config: { version: '1.0.0' } })
|
|
232
|
+
const configStore = app.byKey('config')
|
|
233
|
+
expect(isStore(configStore)).toBe(true)
|
|
234
|
+
expect(configStore).toBe(app.config)
|
|
142
235
|
})
|
|
143
236
|
|
|
144
|
-
test('
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
let lastTheme = ''
|
|
151
|
-
createEffect(() => {
|
|
152
|
-
lastTheme = user.preferences.theme.get()
|
|
153
|
-
})
|
|
237
|
+
test('should return undefined for non-existent key', () => {
|
|
238
|
+
const store = createStore({ name: 'Alice' })
|
|
239
|
+
// @ts-expect-error deliberate access for nonexistent key
|
|
240
|
+
expect(store.byKey('nonexistent')).toBeUndefined()
|
|
241
|
+
})
|
|
242
|
+
})
|
|
154
243
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
244
|
+
describe('keys', () => {
|
|
245
|
+
test('should return an iterator of property keys', () => {
|
|
246
|
+
const store = createStore({ alpha: 1, beta: 2, gamma: 3 })
|
|
247
|
+
expect(Array.from(store.keys())).toEqual(['alpha', 'beta', 'gamma'])
|
|
158
248
|
})
|
|
159
249
|
|
|
160
|
-
test('
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
theme: {
|
|
164
|
-
colors: {
|
|
165
|
-
primary: '#007acc',
|
|
166
|
-
},
|
|
167
|
-
},
|
|
168
|
-
},
|
|
169
|
-
})
|
|
250
|
+
test('should reflect additions and removals', () => {
|
|
251
|
+
const store = createStore<{ a: number; b?: number }>({ a: 1 })
|
|
252
|
+
expect(Array.from(store.keys())).toEqual(['a'])
|
|
170
253
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
254
|
+
store.add('b', 2)
|
|
255
|
+
expect(Array.from(store.keys())).toEqual(['a', 'b'])
|
|
256
|
+
|
|
257
|
+
store.remove('b')
|
|
258
|
+
expect(Array.from(store.keys())).toEqual(['a'])
|
|
174
259
|
})
|
|
175
260
|
})
|
|
176
261
|
|
|
177
|
-
describe('
|
|
178
|
-
test('
|
|
262
|
+
describe('Proxy Behavior', () => {
|
|
263
|
+
test('should access properties directly as signals', () => {
|
|
264
|
+
const user = createStore({ name: 'John', age: 30 })
|
|
265
|
+
expect(user.name.get()).toBe('John')
|
|
266
|
+
expect(user.age.get()).toBe(30)
|
|
267
|
+
|
|
268
|
+
user.name.set('Alicia')
|
|
269
|
+
expect(user.name.get()).toBe('Alicia')
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test('should return undefined for non-existent properties', () => {
|
|
273
|
+
const user = createStore({ name: 'Alice' })
|
|
274
|
+
// @ts-expect-error accessing non-existent property
|
|
275
|
+
expect(user.nonexistent).toBeUndefined()
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
test('should support "in" operator', () => {
|
|
279
|
+
const user = createStore({ name: 'Alice' })
|
|
280
|
+
expect('name' in user).toBe(true)
|
|
281
|
+
expect('email' in user).toBe(false)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('should support Object.keys()', () => {
|
|
179
285
|
const user = createStore({
|
|
180
|
-
name: '
|
|
181
|
-
email: '
|
|
286
|
+
name: 'Alice',
|
|
287
|
+
email: 'alice@example.com',
|
|
182
288
|
})
|
|
183
|
-
user.
|
|
184
|
-
expect(user.name.get()).toBe('Jane')
|
|
185
|
-
expect(user.email.get()).toBe('jane@example.com')
|
|
289
|
+
expect(Object.keys(user).sort()).toEqual(['email', 'name'])
|
|
186
290
|
})
|
|
187
291
|
|
|
188
|
-
test('
|
|
189
|
-
const user = createStore({
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
292
|
+
test('should support for...in enumeration', () => {
|
|
293
|
+
const user = createStore({
|
|
294
|
+
name: 'Alice',
|
|
295
|
+
email: 'alice@example.com',
|
|
296
|
+
})
|
|
297
|
+
const keys: string[] = []
|
|
298
|
+
for (const key in user) keys.push(key)
|
|
299
|
+
expect(keys.sort()).toEqual(['email', 'name'])
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test('should support Object.getOwnPropertyDescriptor', () => {
|
|
303
|
+
const user = createStore({ name: 'Alice' })
|
|
304
|
+
expect(Object.getOwnPropertyDescriptor(user, 'name')).toEqual({
|
|
305
|
+
enumerable: true,
|
|
306
|
+
configurable: true,
|
|
307
|
+
writable: true,
|
|
308
|
+
value: user.name,
|
|
309
|
+
})
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('should return undefined descriptor for Symbol properties', () => {
|
|
313
|
+
const store = createStore({ a: 1 })
|
|
314
|
+
expect(
|
|
315
|
+
Object.getOwnPropertyDescriptor(store, Symbol('test')),
|
|
316
|
+
).toBeUndefined()
|
|
193
317
|
})
|
|
194
318
|
})
|
|
195
319
|
|
|
196
|
-
describe('
|
|
197
|
-
test('
|
|
320
|
+
describe('Iteration', () => {
|
|
321
|
+
test('should support spread operator', () => {
|
|
198
322
|
const user = createStore({ name: 'John', age: 25 })
|
|
199
323
|
const entries = [...user]
|
|
200
324
|
expect(entries).toHaveLength(2)
|
|
@@ -204,16 +328,8 @@ describe('store', () => {
|
|
|
204
328
|
expect(entries[1][1].get()).toBe(25)
|
|
205
329
|
})
|
|
206
330
|
|
|
207
|
-
test('
|
|
208
|
-
const user = createStore({ name: 'John', age: 25 })
|
|
209
|
-
expect(user[Symbol.isConcatSpreadable]).toBe(false)
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
test('maintains property key ordering', () => {
|
|
331
|
+
test('should maintain property key ordering', () => {
|
|
213
332
|
const config = createStore({ alpha: 1, beta: 2, gamma: 3 })
|
|
214
|
-
const keys = Object.keys(config)
|
|
215
|
-
expect(keys).toEqual(['alpha', 'beta', 'gamma'])
|
|
216
|
-
|
|
217
333
|
const entries = [...config]
|
|
218
334
|
expect(entries.map(([key, signal]) => [key, signal.get()])).toEqual(
|
|
219
335
|
[
|
|
@@ -225,8 +341,8 @@ describe('store', () => {
|
|
|
225
341
|
})
|
|
226
342
|
})
|
|
227
343
|
|
|
228
|
-
describe('
|
|
229
|
-
test('
|
|
344
|
+
describe('Reactivity', () => {
|
|
345
|
+
test('should react to property changes via get()', () => {
|
|
230
346
|
const user = createStore({
|
|
231
347
|
name: 'John',
|
|
232
348
|
email: 'john@example.com',
|
|
@@ -235,422 +351,206 @@ describe('store', () => {
|
|
|
235
351
|
createEffect(() => {
|
|
236
352
|
lastValue = user.get()
|
|
237
353
|
})
|
|
238
|
-
|
|
239
354
|
expect(lastValue).toEqual({
|
|
240
355
|
name: 'John',
|
|
241
356
|
email: 'john@example.com',
|
|
242
357
|
})
|
|
243
358
|
|
|
244
359
|
user.name.set('Jane')
|
|
245
|
-
user.email.set('jane@example.com')
|
|
246
|
-
|
|
247
360
|
expect(lastValue).toEqual({
|
|
248
361
|
name: 'Jane',
|
|
249
|
-
email: '
|
|
362
|
+
email: 'john@example.com',
|
|
250
363
|
})
|
|
251
364
|
})
|
|
252
365
|
|
|
253
|
-
test('
|
|
366
|
+
test('should support granular property-level subscriptions', () => {
|
|
254
367
|
const user = createStore({
|
|
255
368
|
name: 'John',
|
|
256
369
|
email: 'john@example.com',
|
|
257
370
|
})
|
|
258
|
-
let
|
|
259
|
-
let nameEffectRuns = 0
|
|
371
|
+
let nameRuns = 0
|
|
260
372
|
createEffect(() => {
|
|
261
|
-
|
|
262
|
-
|
|
373
|
+
user.name.get()
|
|
374
|
+
nameRuns++
|
|
263
375
|
})
|
|
376
|
+
expect(nameRuns).toBe(1)
|
|
264
377
|
|
|
265
|
-
|
|
266
|
-
expect(
|
|
378
|
+
user.email.set('new@example.com')
|
|
379
|
+
expect(nameRuns).toBe(1) // name effect not triggered
|
|
267
380
|
|
|
268
381
|
user.name.set('Jane')
|
|
269
|
-
expect(
|
|
270
|
-
expect(nameEffectRuns).toBe(2)
|
|
382
|
+
expect(nameRuns).toBe(2)
|
|
271
383
|
})
|
|
272
384
|
|
|
273
|
-
test('nested store changes
|
|
385
|
+
test('should propagate nested store changes to parent', () => {
|
|
274
386
|
const user = createStore({
|
|
275
|
-
preferences: {
|
|
276
|
-
theme: 'light',
|
|
277
|
-
},
|
|
387
|
+
preferences: { theme: 'light' },
|
|
278
388
|
})
|
|
279
|
-
let
|
|
389
|
+
let runs = 0
|
|
280
390
|
createEffect(() => {
|
|
281
391
|
user.get()
|
|
282
|
-
|
|
392
|
+
runs++
|
|
283
393
|
})
|
|
394
|
+
expect(runs).toBe(1)
|
|
284
395
|
|
|
285
|
-
expect(effectRuns).toBe(1)
|
|
286
396
|
user.preferences.theme.set('dark')
|
|
287
|
-
expect(
|
|
397
|
+
expect(runs).toBe(2)
|
|
288
398
|
})
|
|
289
399
|
|
|
290
|
-
test('
|
|
291
|
-
const user = createStore({
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
let lastValue: {
|
|
295
|
-
name: string
|
|
296
|
-
email?: string
|
|
297
|
-
} = { name: '' }
|
|
298
|
-
let effectRuns = 0
|
|
400
|
+
test('should react to update()', () => {
|
|
401
|
+
const user = createStore({ name: 'John' })
|
|
402
|
+
let lastValue: { name: string; email?: string } = { name: '' }
|
|
403
|
+
let runs = 0
|
|
299
404
|
createEffect(() => {
|
|
300
405
|
lastValue = user.get()
|
|
301
|
-
|
|
406
|
+
runs++
|
|
302
407
|
})
|
|
303
|
-
|
|
304
|
-
expect(lastValue).toEqual({ name: 'John' })
|
|
305
|
-
expect(effectRuns).toBe(1)
|
|
408
|
+
expect(runs).toBe(1)
|
|
306
409
|
|
|
307
410
|
user.update(u => ({ ...u, email: 'john@example.com' }))
|
|
308
411
|
expect(lastValue).toEqual({
|
|
309
412
|
name: 'John',
|
|
310
413
|
email: 'john@example.com',
|
|
311
414
|
})
|
|
312
|
-
expect(
|
|
415
|
+
expect(runs).toBe(2)
|
|
313
416
|
})
|
|
314
417
|
|
|
315
|
-
test('
|
|
316
|
-
const user = createStore({
|
|
317
|
-
|
|
318
|
-
email: 'john@example.com',
|
|
319
|
-
age: 30,
|
|
320
|
-
})
|
|
321
|
-
let lastValue: {
|
|
322
|
-
name: string
|
|
323
|
-
email?: string
|
|
324
|
-
age?: number
|
|
325
|
-
} = { name: '', email: '', age: 0 }
|
|
326
|
-
let effectRuns = 0
|
|
327
|
-
createEffect(() => {
|
|
328
|
-
lastValue = user.get()
|
|
329
|
-
effectRuns++
|
|
330
|
-
})
|
|
331
|
-
|
|
332
|
-
expect(lastValue).toEqual({
|
|
333
|
-
name: 'John',
|
|
334
|
-
email: 'john@example.com',
|
|
335
|
-
age: 30,
|
|
336
|
-
})
|
|
337
|
-
expect(effectRuns).toBe(1)
|
|
338
|
-
|
|
339
|
-
user.remove('email')
|
|
340
|
-
expect(lastValue).toEqual({ name: 'John', age: 30 })
|
|
341
|
-
expect(effectRuns).toBe(2)
|
|
342
|
-
})
|
|
343
|
-
})
|
|
344
|
-
|
|
345
|
-
describe('computed integration', () => {
|
|
346
|
-
test('works with computed signals', () => {
|
|
347
|
-
const user = createStore({
|
|
348
|
-
firstName: 'John',
|
|
349
|
-
lastName: 'Doe',
|
|
350
|
-
})
|
|
351
|
-
const fullName = new Memo(
|
|
418
|
+
test('should work with createMemo', () => {
|
|
419
|
+
const user = createStore({ firstName: 'John', lastName: 'Doe' })
|
|
420
|
+
const fullName = createMemo(
|
|
352
421
|
() => `${user.firstName.get()} ${user.lastName.get()}`,
|
|
353
422
|
)
|
|
354
|
-
|
|
355
423
|
expect(fullName.get()).toBe('John Doe')
|
|
424
|
+
|
|
356
425
|
user.firstName.set('Jane')
|
|
357
426
|
expect(fullName.get()).toBe('Jane Doe')
|
|
358
427
|
})
|
|
359
428
|
|
|
360
|
-
test('
|
|
361
|
-
const config = createStore({
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
},
|
|
365
|
-
})
|
|
366
|
-
const themeDisplay = new Memo(
|
|
367
|
-
() => `Theme: ${config.ui.theme.get()}`,
|
|
368
|
-
)
|
|
429
|
+
test('should work with createMemo on nested stores', () => {
|
|
430
|
+
const config = createStore({ ui: { theme: 'light' } })
|
|
431
|
+
const display = createMemo(() => `Theme: ${config.ui.theme.get()}`)
|
|
432
|
+
expect(display.get()).toBe('Theme: light')
|
|
369
433
|
|
|
370
|
-
expect(themeDisplay.get()).toBe('Theme: light')
|
|
371
434
|
config.ui.theme.set('dark')
|
|
372
|
-
expect(
|
|
435
|
+
expect(display.get()).toBe('Theme: dark')
|
|
373
436
|
})
|
|
374
437
|
})
|
|
375
438
|
|
|
376
|
-
describe('
|
|
377
|
-
test('
|
|
378
|
-
const
|
|
379
|
-
name: 'Alice',
|
|
380
|
-
|
|
381
|
-
})
|
|
382
|
-
const userKeys = Object.keys(user)
|
|
383
|
-
expect(userKeys.sort()).toEqual(['email', 'name'])
|
|
384
|
-
})
|
|
385
|
-
|
|
386
|
-
test('property enumeration works', () => {
|
|
387
|
-
const user = createStore({
|
|
388
|
-
name: 'Alice',
|
|
389
|
-
email: 'alice@example.com',
|
|
390
|
-
})
|
|
391
|
-
const userKeys: string[] = []
|
|
392
|
-
for (const key in user) {
|
|
393
|
-
userKeys.push(key)
|
|
439
|
+
describe('Serialization', () => {
|
|
440
|
+
test('should round-trip through JSON', () => {
|
|
441
|
+
const data = {
|
|
442
|
+
user: { name: 'Alice', preferences: { theme: 'dark' } },
|
|
443
|
+
settings: { timeout: 5000 },
|
|
394
444
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
test('in operator works', () => {
|
|
399
|
-
const user = createStore({ name: 'Alice' })
|
|
400
|
-
expect('name' in user).toBe(true)
|
|
401
|
-
expect('email' in user).toBe(false)
|
|
402
|
-
})
|
|
403
|
-
|
|
404
|
-
test('Object.getOwnPropertyDescriptor works', () => {
|
|
405
|
-
const user = createStore({ name: 'Alice' })
|
|
406
|
-
const nameDescriptor = Object.getOwnPropertyDescriptor(user, 'name')
|
|
407
|
-
expect(nameDescriptor).toEqual({
|
|
408
|
-
enumerable: true,
|
|
409
|
-
configurable: true,
|
|
410
|
-
writable: true,
|
|
411
|
-
value: user.name,
|
|
412
|
-
})
|
|
445
|
+
const store = createStore(data)
|
|
446
|
+
const parsed = JSON.parse(JSON.stringify(store.get()))
|
|
447
|
+
expect(parsed).toEqual(data)
|
|
413
448
|
})
|
|
414
449
|
})
|
|
415
450
|
|
|
416
|
-
describe('
|
|
417
|
-
test('
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
const nonexistentSignal = user.byKey('nonexistent')
|
|
429
|
-
|
|
430
|
-
expect(nameSignal?.get()).toBe('Alice')
|
|
431
|
-
expect(emailSignal?.get()).toBe('alice@example.com')
|
|
432
|
-
expect(ageSignal?.get()).toBe(30)
|
|
433
|
-
expect(nonexistentSignal).toBeUndefined()
|
|
434
|
-
|
|
435
|
-
// Verify these are the same signals as property access
|
|
436
|
-
expect(nameSignal).toBe(user.name)
|
|
437
|
-
expect(emailSignal).toBe(user.email)
|
|
438
|
-
expect(ageSignal).toBe(user.age)
|
|
439
|
-
})
|
|
440
|
-
|
|
441
|
-
test('works with nested stores', () => {
|
|
442
|
-
const app = createStore({
|
|
443
|
-
config: {
|
|
444
|
-
version: '1.0.0',
|
|
451
|
+
describe('options.watched', () => {
|
|
452
|
+
test('should activate on first effect and clean up on last', () => {
|
|
453
|
+
let watchCount = 0
|
|
454
|
+
const store = createStore(
|
|
455
|
+
{ users: {} as Record<string, { name: string }> },
|
|
456
|
+
{
|
|
457
|
+
watched: () => {
|
|
458
|
+
watchCount++
|
|
459
|
+
return () => {
|
|
460
|
+
watchCount--
|
|
461
|
+
}
|
|
462
|
+
},
|
|
445
463
|
},
|
|
446
|
-
})
|
|
447
|
-
|
|
448
|
-
const configStore = app.byKey('config')
|
|
449
|
-
expect(configStore?.get()).toEqual({ version: '1.0.0' })
|
|
450
|
-
expect(configStore).toBe(app.config)
|
|
451
|
-
})
|
|
452
|
-
|
|
453
|
-
test('is reactive and works with computed signals', () => {
|
|
454
|
-
const user = createStore<{
|
|
455
|
-
name: string
|
|
456
|
-
age: number
|
|
457
|
-
}>({
|
|
458
|
-
name: 'Alice',
|
|
459
|
-
age: 30,
|
|
460
|
-
})
|
|
461
|
-
|
|
462
|
-
const nameSignal = user.byKey('name')
|
|
463
|
-
const displayName = new Memo(() =>
|
|
464
|
-
nameSignal ? `Hello, ${nameSignal.get()}!` : 'Unknown',
|
|
465
464
|
)
|
|
465
|
+
expect(watchCount).toBe(0)
|
|
466
466
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
expect(displayName.get()).toBe('Hello, Bob!')
|
|
470
|
-
})
|
|
471
|
-
})
|
|
472
|
-
|
|
473
|
-
describe('UNSET and edge cases', () => {
|
|
474
|
-
test('handles UNSET values', () => {
|
|
475
|
-
const store = createStore({ value: UNSET })
|
|
476
|
-
expect(store.get()).toEqual({ value: UNSET })
|
|
477
|
-
})
|
|
478
|
-
|
|
479
|
-
test('handles primitive values', () => {
|
|
480
|
-
const store = createStore({
|
|
481
|
-
str: 'hello',
|
|
482
|
-
num: 42,
|
|
483
|
-
bool: true,
|
|
467
|
+
const cleanup1 = createEffect(() => {
|
|
468
|
+
store.get()
|
|
484
469
|
})
|
|
485
|
-
expect(
|
|
486
|
-
expect(store.num.get()).toBe(42)
|
|
487
|
-
expect(store.bool.get()).toBe(true)
|
|
488
|
-
})
|
|
489
|
-
|
|
490
|
-
test('handles empty stores correctly', () => {
|
|
491
|
-
const empty = createStore({})
|
|
492
|
-
expect(empty.get()).toEqual({})
|
|
493
|
-
})
|
|
494
|
-
})
|
|
495
|
-
|
|
496
|
-
describe('JSON integration and serialization', () => {
|
|
497
|
-
test('seamless JSON integration', () => {
|
|
498
|
-
const jsonData = {
|
|
499
|
-
user: { name: 'Alice', preferences: { theme: 'dark' } },
|
|
500
|
-
settings: { timeout: 5000 },
|
|
501
|
-
}
|
|
502
|
-
const store = createStore(jsonData)
|
|
503
|
-
|
|
504
|
-
expect(store.user.name.get()).toBe('Alice')
|
|
505
|
-
expect(store.user.preferences.theme.get()).toBe('dark')
|
|
506
|
-
expect(store.settings.timeout.get()).toBe(5000)
|
|
507
|
-
|
|
508
|
-
const serialized = JSON.stringify(store.get())
|
|
509
|
-
const parsed = JSON.parse(serialized)
|
|
510
|
-
expect(parsed).toEqual(jsonData)
|
|
511
|
-
})
|
|
512
|
-
|
|
513
|
-
test('handles complex nested structures from JSON', () => {
|
|
514
|
-
type Dashboard = {
|
|
515
|
-
dashboard: {
|
|
516
|
-
widgets: Array<{
|
|
517
|
-
id: string
|
|
518
|
-
type: string
|
|
519
|
-
config: { color?: string; rows?: number }
|
|
520
|
-
}>
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
const complexData: Dashboard = {
|
|
525
|
-
dashboard: {
|
|
526
|
-
widgets: [
|
|
527
|
-
{ id: '1', type: 'chart', config: { color: 'blue' } },
|
|
528
|
-
{ id: '2', type: 'table', config: { rows: 10 } },
|
|
529
|
-
],
|
|
530
|
-
},
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
const store = createStore(complexData)
|
|
534
|
-
expect(store.dashboard.widgets.at(0)?.get().config.color).toBe(
|
|
535
|
-
'blue',
|
|
536
|
-
)
|
|
537
|
-
expect(store.dashboard.widgets.at(1)?.get().config.rows).toBe(10)
|
|
538
|
-
})
|
|
539
|
-
})
|
|
470
|
+
expect(watchCount).toBe(1)
|
|
540
471
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
const config = createStore({
|
|
544
|
-
database: {
|
|
545
|
-
host: 'localhost',
|
|
546
|
-
port: 5432,
|
|
547
|
-
},
|
|
472
|
+
const cleanup2 = createEffect(() => {
|
|
473
|
+
store.get()
|
|
548
474
|
})
|
|
475
|
+
expect(watchCount).toBe(1) // still 1
|
|
476
|
+
|
|
477
|
+
cleanup2()
|
|
478
|
+
expect(watchCount).toBe(1) // still active
|
|
549
479
|
|
|
550
|
-
|
|
551
|
-
expect(
|
|
552
|
-
expect(config.database.port.get()).toBe(5432)
|
|
480
|
+
cleanup1()
|
|
481
|
+
expect(watchCount).toBe(0) // cleaned up
|
|
553
482
|
})
|
|
554
|
-
})
|
|
555
483
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
let rootStoreCounter = 0
|
|
559
|
-
let intervalId: Timer | undefined
|
|
484
|
+
test('should not activate for nested property access only', async () => {
|
|
485
|
+
let activated = false
|
|
560
486
|
const store = createStore(
|
|
561
|
-
{
|
|
562
|
-
user: {
|
|
563
|
-
name: 'John',
|
|
564
|
-
profile: {
|
|
565
|
-
email: 'john@example.com',
|
|
566
|
-
},
|
|
567
|
-
},
|
|
568
|
-
},
|
|
487
|
+
{ user: { name: 'John' } },
|
|
569
488
|
{
|
|
570
489
|
watched: () => {
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
},
|
|
575
|
-
unwatched: () => {
|
|
576
|
-
if (intervalId) {
|
|
577
|
-
clearInterval(intervalId)
|
|
578
|
-
intervalId = undefined
|
|
490
|
+
activated = true
|
|
491
|
+
return () => {
|
|
492
|
+
activated = false
|
|
579
493
|
}
|
|
580
494
|
},
|
|
581
495
|
},
|
|
582
496
|
)
|
|
583
497
|
|
|
584
|
-
|
|
585
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
586
|
-
expect(rootStoreCounter).toBe(0)
|
|
587
|
-
|
|
588
|
-
// Access nested property directly - should NOT trigger root watched callback
|
|
589
|
-
const nestedEffectCleanup = createEffect(() => {
|
|
498
|
+
const cleanup = createEffect(() => {
|
|
590
499
|
store.user.name.get()
|
|
591
500
|
})
|
|
501
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
502
|
+
expect(activated).toBe(false)
|
|
592
503
|
|
|
593
|
-
|
|
594
|
-
expect(rootStoreCounter).toBe(0) // Still 0 - nested access doesn't trigger root
|
|
595
|
-
expect(intervalId).toBeUndefined()
|
|
596
|
-
|
|
597
|
-
// Access root store directly - should trigger watched callback
|
|
598
|
-
const rootEffectCleanup = createEffect(() => {
|
|
599
|
-
store.get()
|
|
600
|
-
})
|
|
601
|
-
|
|
602
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
603
|
-
expect(rootStoreCounter).toBeGreaterThan(0) // Now triggered
|
|
604
|
-
expect(intervalId).toBeDefined()
|
|
605
|
-
|
|
606
|
-
// Cleanup
|
|
607
|
-
rootEffectCleanup()
|
|
608
|
-
nestedEffectCleanup()
|
|
609
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
610
|
-
expect(intervalId).toBeUndefined()
|
|
504
|
+
cleanup()
|
|
611
505
|
})
|
|
612
506
|
|
|
613
|
-
test('
|
|
614
|
-
let
|
|
615
|
-
const store = createStore(
|
|
616
|
-
{
|
|
617
|
-
users: {} as Record<string, { name: string }>,
|
|
618
|
-
},
|
|
507
|
+
test('should activate when keys() is called in an effect', () => {
|
|
508
|
+
let watchCount = 0
|
|
509
|
+
const store = createStore<{ a: number; b?: number }>(
|
|
510
|
+
{ a: 1 },
|
|
619
511
|
{
|
|
620
512
|
watched: () => {
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
513
|
+
watchCount++
|
|
514
|
+
return () => {
|
|
515
|
+
watchCount--
|
|
516
|
+
}
|
|
625
517
|
},
|
|
626
518
|
},
|
|
627
519
|
)
|
|
520
|
+
expect(watchCount).toBe(0)
|
|
628
521
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
// Watch the entire store
|
|
632
|
-
const usersEffect = createEffect(() => {
|
|
633
|
-
store.get()
|
|
522
|
+
const cleanup = createEffect(() => {
|
|
523
|
+
Array.from(store.keys()).forEach(() => {})
|
|
634
524
|
})
|
|
635
|
-
expect(
|
|
525
|
+
expect(watchCount).toBe(1)
|
|
636
526
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
527
|
+
cleanup()
|
|
528
|
+
expect(watchCount).toBe(0)
|
|
529
|
+
})
|
|
530
|
+
})
|
|
640
531
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
532
|
+
describe('Input Validation', () => {
|
|
533
|
+
test('should throw for null initial value', () => {
|
|
534
|
+
// @ts-expect-error testing null
|
|
535
|
+
expect(() => createStore(null)).toThrow()
|
|
536
|
+
})
|
|
646
537
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
expect(
|
|
538
|
+
test('should throw for undefined initial value', () => {
|
|
539
|
+
// @ts-expect-error testing undefined
|
|
540
|
+
expect(() => createStore(undefined)).toThrow()
|
|
541
|
+
})
|
|
650
542
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
expect(
|
|
543
|
+
test('should throw for non-object initial value', () => {
|
|
544
|
+
// @ts-expect-error testing primitive
|
|
545
|
+
expect(() => createStore('hello')).toThrow()
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
test('should throw for null value in add()', () => {
|
|
549
|
+
const store = createStore<{ name: string; email?: string }>({
|
|
550
|
+
name: 'John',
|
|
551
|
+
})
|
|
552
|
+
// @ts-expect-error testing null
|
|
553
|
+
expect(() => store.add('email', null)).toThrow()
|
|
654
554
|
})
|
|
655
555
|
})
|
|
656
556
|
})
|