@zeix/cause-effect 0.17.2 → 0.18.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/.ai-context.md +163 -226
- package/.cursorrules +41 -35
- package/.github/copilot-instructions.md +166 -116
- package/.zed/settings.json +3 -0
- package/ARCHITECTURE.md +274 -0
- package/CLAUDE.md +197 -202
- package/COLLECTION_REFACTORING.md +161 -0
- package/GUIDE.md +298 -0
- package/README.md +241 -220
- package/REQUIREMENTS.md +100 -0
- package/bench/reactivity.bench.ts +577 -0
- package/index.dev.js +1326 -1174
- package/index.js +1 -1
- package/index.ts +58 -85
- package/package.json +9 -6
- package/src/errors.ts +118 -70
- package/src/graph.ts +601 -0
- package/src/nodes/collection.ts +474 -0
- package/src/nodes/effect.ts +149 -0
- package/src/nodes/list.ts +588 -0
- package/src/nodes/memo.ts +120 -0
- package/src/nodes/sensor.ts +139 -0
- package/src/nodes/state.ts +135 -0
- package/src/nodes/store.ts +383 -0
- package/src/nodes/task.ts +146 -0
- package/src/signal.ts +112 -64
- package/src/util.ts +26 -57
- package/test/batch.test.ts +96 -69
- package/test/benchmark.test.ts +473 -485
- package/test/collection.test.ts +455 -955
- package/test/effect.test.ts +293 -696
- package/test/list.test.ts +332 -857
- package/test/memo.test.ts +380 -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 -271
- package/test/store.test.ts +346 -898
- package/test/task.test.ts +395 -0
- package/test/untrack.test.ts +167 -0
- package/test/util/dependency-graph.ts +2 -2
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +5 -7
- package/types/index.d.ts +13 -15
- package/types/src/errors.d.ts +73 -19
- package/types/src/graph.d.ts +208 -0
- package/types/src/nodes/collection.d.ts +64 -0
- package/types/src/nodes/effect.d.ts +48 -0
- package/types/src/nodes/list.d.ts +65 -0
- package/types/src/nodes/memo.d.ts +57 -0
- package/types/src/nodes/sensor.d.ts +75 -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 +73 -0
- package/types/src/signal.d.ts +43 -28
- package/types/src/util.d.ts +9 -16
- package/archive/benchmark.ts +0 -688
- package/archive/collection.ts +0 -310
- package/archive/computed.ts +0 -198
- package/archive/list.ts +0 -544
- package/archive/memo.ts +0 -140
- package/archive/state.ts +0 -90
- package/archive/store.ts +0 -357
- package/archive/task.ts +0 -191
- package/src/classes/collection.ts +0 -298
- package/src/classes/composite.ts +0 -171
- package/src/classes/computed.ts +0 -392
- package/src/classes/list.ts +0 -310
- package/src/classes/ref.ts +0 -96
- package/src/classes/state.ts +0 -131
- package/src/classes/store.ts +0 -227
- package/src/diff.ts +0 -138
- package/src/effect.ts +0 -96
- package/src/match.ts +0 -45
- package/src/resolve.ts +0 -49
- package/src/system.ts +0 -275
- package/test/computed.test.ts +0 -1126
- package/test/diff.test.ts +0 -955
- package/test/match.test.ts +0 -388
- package/test/ref.test.ts +0 -381
- package/test/resolve.test.ts +0 -154
- package/types/src/classes/collection.d.ts +0 -47
- package/types/src/classes/composite.d.ts +0 -15
- package/types/src/classes/computed.d.ts +0 -114
- package/types/src/classes/list.d.ts +0 -41
- package/types/src/classes/ref.d.ts +0 -48
- package/types/src/classes/state.d.ts +0 -61
- package/types/src/classes/store.d.ts +0 -51
- 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 -81
package/test/store.test.ts
CHANGED
|
@@ -1,52 +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
|
-
import { HOOK_WATCH } from '../src/system'
|
|
12
10
|
|
|
13
|
-
describe('
|
|
14
|
-
describe('
|
|
15
|
-
test('
|
|
16
|
-
const user =
|
|
11
|
+
describe('Store', () => {
|
|
12
|
+
describe('createStore', () => {
|
|
13
|
+
test('should create a store with initial values', () => {
|
|
14
|
+
const user = createStore({
|
|
17
15
|
name: 'Hannah',
|
|
18
16
|
email: 'hannah@example.com',
|
|
19
17
|
})
|
|
20
|
-
expect(user.
|
|
21
|
-
expect(user.
|
|
18
|
+
expect(user.name.get()).toBe('Hannah')
|
|
19
|
+
expect(user.email.get()).toBe('hannah@example.com')
|
|
22
20
|
})
|
|
23
21
|
|
|
24
|
-
test('
|
|
22
|
+
test('should create nested stores for object properties', () => {
|
|
25
23
|
const user = createStore({
|
|
26
|
-
name: '
|
|
27
|
-
|
|
24
|
+
name: 'Alice',
|
|
25
|
+
preferences: { theme: 'light', notifications: true },
|
|
28
26
|
})
|
|
29
|
-
expect(user.
|
|
30
|
-
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({})
|
|
31
50
|
})
|
|
32
51
|
|
|
33
|
-
test('
|
|
52
|
+
test('should have Symbol.toStringTag of "Store"', () => {
|
|
34
53
|
const store = createStore({ a: 1 })
|
|
35
54
|
expect(store[Symbol.toStringTag]).toBe('Store')
|
|
36
55
|
})
|
|
37
56
|
|
|
38
|
-
test('
|
|
57
|
+
test('should have Symbol.isConcatSpreadable set to false', () => {
|
|
39
58
|
const store = createStore({ a: 1 })
|
|
40
|
-
|
|
41
|
-
|
|
59
|
+
expect(store[Symbol.isConcatSpreadable]).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
42
62
|
|
|
63
|
+
describe('isStore', () => {
|
|
64
|
+
test('should return true for store instances', () => {
|
|
65
|
+
const store = createStore({ a: 1 })
|
|
43
66
|
expect(isStore(store)).toBe(true)
|
|
44
|
-
|
|
45
|
-
|
|
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)
|
|
46
72
|
expect(isStore({})).toBe(false)
|
|
73
|
+
expect(isStore(null)).toBe(false)
|
|
47
74
|
})
|
|
75
|
+
})
|
|
48
76
|
|
|
49
|
-
|
|
77
|
+
describe('get', () => {
|
|
78
|
+
test('should return the complete store value', () => {
|
|
50
79
|
const user = createStore({
|
|
51
80
|
name: 'Alice',
|
|
52
81
|
email: 'alice@example.com',
|
|
@@ -56,146 +85,240 @@ describe('store', () => {
|
|
|
56
85
|
email: 'alice@example.com',
|
|
57
86
|
})
|
|
58
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
|
+
})
|
|
59
94
|
})
|
|
60
95
|
|
|
61
|
-
describe('
|
|
62
|
-
test('
|
|
63
|
-
const user = createStore({
|
|
64
|
-
|
|
65
|
-
|
|
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
|
+
})
|
|
66
106
|
|
|
67
|
-
|
|
68
|
-
user
|
|
69
|
-
|
|
70
|
-
|
|
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)
|
|
71
126
|
})
|
|
72
127
|
|
|
73
|
-
test('
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
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)
|
|
77
139
|
})
|
|
140
|
+
})
|
|
78
141
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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)
|
|
83
148
|
})
|
|
84
149
|
})
|
|
85
150
|
|
|
86
|
-
describe('add
|
|
87
|
-
test('add
|
|
151
|
+
describe('add', () => {
|
|
152
|
+
test('should add a new property', () => {
|
|
88
153
|
const user = createStore<{ name: string; email?: string }>({
|
|
89
154
|
name: 'John',
|
|
90
155
|
})
|
|
91
156
|
user.add('email', 'john@example.com')
|
|
92
|
-
expect(user.byKey('email')?.get()).toBe('john@example.com')
|
|
93
157
|
expect(user.email?.get()).toBe('john@example.com')
|
|
158
|
+
expect(user.byKey('email')?.get()).toBe('john@example.com')
|
|
94
159
|
})
|
|
95
160
|
|
|
96
|
-
test('
|
|
161
|
+
test('should throw DuplicateKeyError for existing key', () => {
|
|
162
|
+
const user = createStore({ name: 'John' })
|
|
163
|
+
expect(() => user.add('name', 'Jane')).toThrow()
|
|
164
|
+
})
|
|
165
|
+
|
|
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', () => {
|
|
97
186
|
const user = createStore<{ name: string; email?: string }>({
|
|
98
187
|
name: 'John',
|
|
99
188
|
email: 'john@example.com',
|
|
100
189
|
})
|
|
101
190
|
user.remove('email')
|
|
102
191
|
expect(user.byKey('email')).toBeUndefined()
|
|
103
|
-
// expect(user.byKey('name').get()).toBe('John')
|
|
104
192
|
expect(user.email).toBeUndefined()
|
|
105
|
-
// expect(user.name.get()).toBe('John')
|
|
106
193
|
})
|
|
107
194
|
|
|
108
|
-
test('
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
})
|
|
112
|
-
// @ts-expect-error testing null values
|
|
113
|
-
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()
|
|
114
198
|
})
|
|
115
199
|
|
|
116
|
-
test('
|
|
117
|
-
const
|
|
200
|
+
test('should be reactive', () => {
|
|
201
|
+
const store = createStore({
|
|
118
202
|
name: 'John',
|
|
119
203
|
email: 'john@example.com',
|
|
120
204
|
})
|
|
121
|
-
|
|
122
|
-
|
|
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)
|
|
123
215
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
expect(
|
|
216
|
+
store.remove('email')
|
|
217
|
+
expect(lastValue).toEqual({ name: 'John' })
|
|
218
|
+
expect(runs).toBe(2)
|
|
127
219
|
})
|
|
128
220
|
})
|
|
129
221
|
|
|
130
|
-
describe('
|
|
131
|
-
test('
|
|
132
|
-
const user = createStore({
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
},
|
|
138
|
-
})
|
|
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
|
+
})
|
|
139
229
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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)
|
|
143
235
|
})
|
|
144
236
|
|
|
145
|
-
test('
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
let lastTheme = ''
|
|
152
|
-
createEffect(() => {
|
|
153
|
-
lastTheme = user.preferences.theme.get()
|
|
154
|
-
})
|
|
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
|
+
})
|
|
155
243
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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'])
|
|
159
248
|
})
|
|
160
249
|
|
|
161
|
-
test('
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
theme: {
|
|
165
|
-
colors: {
|
|
166
|
-
primary: '#007acc',
|
|
167
|
-
},
|
|
168
|
-
},
|
|
169
|
-
},
|
|
170
|
-
})
|
|
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'])
|
|
171
253
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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'])
|
|
175
259
|
})
|
|
176
260
|
})
|
|
177
261
|
|
|
178
|
-
describe('
|
|
179
|
-
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()', () => {
|
|
180
285
|
const user = createStore({
|
|
181
|
-
name: '
|
|
182
|
-
email: '
|
|
286
|
+
name: 'Alice',
|
|
287
|
+
email: 'alice@example.com',
|
|
183
288
|
})
|
|
184
|
-
user.
|
|
185
|
-
expect(user.name.get()).toBe('Jane')
|
|
186
|
-
expect(user.email.get()).toBe('jane@example.com')
|
|
289
|
+
expect(Object.keys(user).sort()).toEqual(['email', 'name'])
|
|
187
290
|
})
|
|
188
291
|
|
|
189
|
-
test('
|
|
190
|
-
const user = createStore({
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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()
|
|
194
317
|
})
|
|
195
318
|
})
|
|
196
319
|
|
|
197
|
-
describe('
|
|
198
|
-
test('
|
|
320
|
+
describe('Iteration', () => {
|
|
321
|
+
test('should support spread operator', () => {
|
|
199
322
|
const user = createStore({ name: 'John', age: 25 })
|
|
200
323
|
const entries = [...user]
|
|
201
324
|
expect(entries).toHaveLength(2)
|
|
@@ -205,16 +328,8 @@ describe('store', () => {
|
|
|
205
328
|
expect(entries[1][1].get()).toBe(25)
|
|
206
329
|
})
|
|
207
330
|
|
|
208
|
-
test('
|
|
209
|
-
const user = createStore({ name: 'John', age: 25 })
|
|
210
|
-
expect(user[Symbol.isConcatSpreadable]).toBe(false)
|
|
211
|
-
})
|
|
212
|
-
|
|
213
|
-
test('maintains property key ordering', () => {
|
|
331
|
+
test('should maintain property key ordering', () => {
|
|
214
332
|
const config = createStore({ alpha: 1, beta: 2, gamma: 3 })
|
|
215
|
-
const keys = Object.keys(config)
|
|
216
|
-
expect(keys).toEqual(['alpha', 'beta', 'gamma'])
|
|
217
|
-
|
|
218
333
|
const entries = [...config]
|
|
219
334
|
expect(entries.map(([key, signal]) => [key, signal.get()])).toEqual(
|
|
220
335
|
[
|
|
@@ -226,116 +341,8 @@ describe('store', () => {
|
|
|
226
341
|
})
|
|
227
342
|
})
|
|
228
343
|
|
|
229
|
-
describe('
|
|
230
|
-
test('
|
|
231
|
-
let addedKeys: readonly string[] | undefined
|
|
232
|
-
const user = createStore<{ name: string; email?: string }>({
|
|
233
|
-
name: 'John',
|
|
234
|
-
})
|
|
235
|
-
user.on('add', add => {
|
|
236
|
-
addedKeys = add
|
|
237
|
-
})
|
|
238
|
-
user.add('email', 'john@example.com')
|
|
239
|
-
expect(addedKeys).toContain('email')
|
|
240
|
-
})
|
|
241
|
-
|
|
242
|
-
test('triggers HOOK_CHANGE when properties are modified', () => {
|
|
243
|
-
const user = createStore({ name: 'John' })
|
|
244
|
-
let changedKeys: readonly string[] | undefined
|
|
245
|
-
user.on('change', change => {
|
|
246
|
-
changedKeys = change
|
|
247
|
-
})
|
|
248
|
-
user.name.set('Jane')
|
|
249
|
-
expect(changedKeys).toContain('name')
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
test('triggers HOOK_CHANGE for nested property changes', () => {
|
|
253
|
-
const user = createStore({
|
|
254
|
-
preferences: {
|
|
255
|
-
theme: 'light',
|
|
256
|
-
},
|
|
257
|
-
})
|
|
258
|
-
let changedKeys: readonly string[] | undefined
|
|
259
|
-
user.on('change', change => {
|
|
260
|
-
changedKeys = change
|
|
261
|
-
})
|
|
262
|
-
user.preferences.theme.set('dark')
|
|
263
|
-
expect(changedKeys).toContain('preferences')
|
|
264
|
-
})
|
|
265
|
-
|
|
266
|
-
test('triggers HOOK_REMOVE when properties are removed', () => {
|
|
267
|
-
const user = createStore({
|
|
268
|
-
name: 'John',
|
|
269
|
-
email: 'john@example.com',
|
|
270
|
-
})
|
|
271
|
-
let removedKeys: readonly string[] | undefined
|
|
272
|
-
user.on('remove', remove => {
|
|
273
|
-
removedKeys = remove
|
|
274
|
-
})
|
|
275
|
-
user.remove('email')
|
|
276
|
-
expect(removedKeys).toContain('email')
|
|
277
|
-
})
|
|
278
|
-
|
|
279
|
-
test('set() correctly handles mixed changes, additions, and removals', () => {
|
|
280
|
-
const user = createStore<{
|
|
281
|
-
name: string
|
|
282
|
-
email?: string
|
|
283
|
-
preferences: { theme?: string }
|
|
284
|
-
age?: number
|
|
285
|
-
}>({
|
|
286
|
-
name: 'John',
|
|
287
|
-
email: 'john@example.com',
|
|
288
|
-
preferences: {
|
|
289
|
-
theme: 'light',
|
|
290
|
-
},
|
|
291
|
-
})
|
|
292
|
-
|
|
293
|
-
let changedKeys: readonly string[] | undefined
|
|
294
|
-
let addedKeys: readonly string[] | undefined
|
|
295
|
-
let removedKeys: readonly string[] | undefined
|
|
296
|
-
|
|
297
|
-
user.on('change', change => {
|
|
298
|
-
changedKeys = change
|
|
299
|
-
})
|
|
300
|
-
user.on('add', add => {
|
|
301
|
-
addedKeys = add
|
|
302
|
-
})
|
|
303
|
-
user.on('remove', remove => {
|
|
304
|
-
removedKeys = remove
|
|
305
|
-
})
|
|
306
|
-
|
|
307
|
-
user.set({
|
|
308
|
-
name: 'Jane',
|
|
309
|
-
preferences: {
|
|
310
|
-
theme: 'dark',
|
|
311
|
-
},
|
|
312
|
-
age: 30,
|
|
313
|
-
})
|
|
314
|
-
|
|
315
|
-
expect(changedKeys).toContain('name')
|
|
316
|
-
expect(changedKeys).toContain('preferences')
|
|
317
|
-
expect(addedKeys).toContain('age')
|
|
318
|
-
expect(removedKeys).toContain('email')
|
|
319
|
-
})
|
|
320
|
-
|
|
321
|
-
test('hooks can be removed', () => {
|
|
322
|
-
const user = createStore({ name: 'John' })
|
|
323
|
-
let notificationCount = 0
|
|
324
|
-
const listener = () => {
|
|
325
|
-
notificationCount++
|
|
326
|
-
}
|
|
327
|
-
const off = user.on('change', listener)
|
|
328
|
-
user.name.set('Jane')
|
|
329
|
-
expect(notificationCount).toBe(1)
|
|
330
|
-
|
|
331
|
-
off()
|
|
332
|
-
user.name.set('Bob')
|
|
333
|
-
expect(notificationCount).toBe(1)
|
|
334
|
-
})
|
|
335
|
-
})
|
|
336
|
-
|
|
337
|
-
describe('reactivity', () => {
|
|
338
|
-
test('store-level get() is reactive', () => {
|
|
344
|
+
describe('Reactivity', () => {
|
|
345
|
+
test('should react to property changes via get()', () => {
|
|
339
346
|
const user = createStore({
|
|
340
347
|
name: 'John',
|
|
341
348
|
email: 'john@example.com',
|
|
@@ -344,765 +351,206 @@ describe('store', () => {
|
|
|
344
351
|
createEffect(() => {
|
|
345
352
|
lastValue = user.get()
|
|
346
353
|
})
|
|
347
|
-
|
|
348
354
|
expect(lastValue).toEqual({
|
|
349
355
|
name: 'John',
|
|
350
356
|
email: 'john@example.com',
|
|
351
357
|
})
|
|
352
358
|
|
|
353
359
|
user.name.set('Jane')
|
|
354
|
-
user.email.set('jane@example.com')
|
|
355
|
-
|
|
356
360
|
expect(lastValue).toEqual({
|
|
357
361
|
name: 'Jane',
|
|
358
|
-
email: '
|
|
362
|
+
email: 'john@example.com',
|
|
359
363
|
})
|
|
360
364
|
})
|
|
361
365
|
|
|
362
|
-
test('
|
|
366
|
+
test('should support granular property-level subscriptions', () => {
|
|
363
367
|
const user = createStore({
|
|
364
368
|
name: 'John',
|
|
365
369
|
email: 'john@example.com',
|
|
366
370
|
})
|
|
367
|
-
let
|
|
368
|
-
let nameEffectRuns = 0
|
|
371
|
+
let nameRuns = 0
|
|
369
372
|
createEffect(() => {
|
|
370
|
-
|
|
371
|
-
|
|
373
|
+
user.name.get()
|
|
374
|
+
nameRuns++
|
|
372
375
|
})
|
|
376
|
+
expect(nameRuns).toBe(1)
|
|
373
377
|
|
|
374
|
-
|
|
375
|
-
expect(
|
|
378
|
+
user.email.set('new@example.com')
|
|
379
|
+
expect(nameRuns).toBe(1) // name effect not triggered
|
|
376
380
|
|
|
377
381
|
user.name.set('Jane')
|
|
378
|
-
expect(
|
|
379
|
-
expect(nameEffectRuns).toBe(2)
|
|
382
|
+
expect(nameRuns).toBe(2)
|
|
380
383
|
})
|
|
381
384
|
|
|
382
|
-
test('nested store changes
|
|
385
|
+
test('should propagate nested store changes to parent', () => {
|
|
383
386
|
const user = createStore({
|
|
384
|
-
preferences: {
|
|
385
|
-
theme: 'light',
|
|
386
|
-
},
|
|
387
|
+
preferences: { theme: 'light' },
|
|
387
388
|
})
|
|
388
|
-
let
|
|
389
|
+
let runs = 0
|
|
389
390
|
createEffect(() => {
|
|
390
391
|
user.get()
|
|
391
|
-
|
|
392
|
+
runs++
|
|
392
393
|
})
|
|
394
|
+
expect(runs).toBe(1)
|
|
393
395
|
|
|
394
|
-
expect(effectRuns).toBe(1)
|
|
395
396
|
user.preferences.theme.set('dark')
|
|
396
|
-
expect(
|
|
397
|
+
expect(runs).toBe(2)
|
|
397
398
|
})
|
|
398
399
|
|
|
399
|
-
test('
|
|
400
|
-
const user = createStore({
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
let lastValue: {
|
|
404
|
-
name: string
|
|
405
|
-
email?: string
|
|
406
|
-
} = { name: '' }
|
|
407
|
-
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
|
|
408
404
|
createEffect(() => {
|
|
409
405
|
lastValue = user.get()
|
|
410
|
-
|
|
406
|
+
runs++
|
|
411
407
|
})
|
|
412
|
-
|
|
413
|
-
expect(lastValue).toEqual({ name: 'John' })
|
|
414
|
-
expect(effectRuns).toBe(1)
|
|
408
|
+
expect(runs).toBe(1)
|
|
415
409
|
|
|
416
410
|
user.update(u => ({ ...u, email: 'john@example.com' }))
|
|
417
411
|
expect(lastValue).toEqual({
|
|
418
412
|
name: 'John',
|
|
419
413
|
email: 'john@example.com',
|
|
420
414
|
})
|
|
421
|
-
expect(
|
|
422
|
-
})
|
|
423
|
-
|
|
424
|
-
test('remove method is reactive', () => {
|
|
425
|
-
const user = createStore({
|
|
426
|
-
name: 'John',
|
|
427
|
-
email: 'john@example.com',
|
|
428
|
-
age: 30,
|
|
429
|
-
})
|
|
430
|
-
let lastValue: {
|
|
431
|
-
name: string
|
|
432
|
-
email?: string
|
|
433
|
-
age?: number
|
|
434
|
-
} = { name: '', email: '', age: 0 }
|
|
435
|
-
let effectRuns = 0
|
|
436
|
-
createEffect(() => {
|
|
437
|
-
lastValue = user.get()
|
|
438
|
-
effectRuns++
|
|
439
|
-
})
|
|
440
|
-
|
|
441
|
-
expect(lastValue).toEqual({
|
|
442
|
-
name: 'John',
|
|
443
|
-
email: 'john@example.com',
|
|
444
|
-
age: 30,
|
|
445
|
-
})
|
|
446
|
-
expect(effectRuns).toBe(1)
|
|
447
|
-
|
|
448
|
-
user.remove('email')
|
|
449
|
-
expect(lastValue).toEqual({ name: 'John', age: 30 })
|
|
450
|
-
expect(effectRuns).toBe(2)
|
|
415
|
+
expect(runs).toBe(2)
|
|
451
416
|
})
|
|
452
|
-
})
|
|
453
417
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const
|
|
457
|
-
firstName: 'John',
|
|
458
|
-
lastName: 'Doe',
|
|
459
|
-
})
|
|
460
|
-
const fullName = new Memo(
|
|
418
|
+
test('should work with createMemo', () => {
|
|
419
|
+
const user = createStore({ firstName: 'John', lastName: 'Doe' })
|
|
420
|
+
const fullName = createMemo(
|
|
461
421
|
() => `${user.firstName.get()} ${user.lastName.get()}`,
|
|
462
422
|
)
|
|
463
|
-
|
|
464
423
|
expect(fullName.get()).toBe('John Doe')
|
|
424
|
+
|
|
465
425
|
user.firstName.set('Jane')
|
|
466
426
|
expect(fullName.get()).toBe('Jane Doe')
|
|
467
427
|
})
|
|
468
428
|
|
|
469
|
-
test('
|
|
470
|
-
const config = createStore({
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
},
|
|
474
|
-
})
|
|
475
|
-
const themeDisplay = new Memo(
|
|
476
|
-
() => `Theme: ${config.ui.theme.get()}`,
|
|
477
|
-
)
|
|
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')
|
|
478
433
|
|
|
479
|
-
expect(themeDisplay.get()).toBe('Theme: light')
|
|
480
434
|
config.ui.theme.set('dark')
|
|
481
|
-
expect(
|
|
435
|
+
expect(display.get()).toBe('Theme: dark')
|
|
482
436
|
})
|
|
483
437
|
})
|
|
484
438
|
|
|
485
|
-
describe('
|
|
486
|
-
test('
|
|
487
|
-
const
|
|
488
|
-
name: 'Alice',
|
|
489
|
-
email: 'alice@example.com',
|
|
490
|
-
})
|
|
491
|
-
const userKeys = Object.keys(user)
|
|
492
|
-
expect(userKeys.sort()).toEqual(['email', 'name'])
|
|
493
|
-
})
|
|
494
|
-
|
|
495
|
-
test('property enumeration works', () => {
|
|
496
|
-
const user = createStore({
|
|
497
|
-
name: 'Alice',
|
|
498
|
-
email: 'alice@example.com',
|
|
499
|
-
})
|
|
500
|
-
const userKeys: string[] = []
|
|
501
|
-
for (const key in user) {
|
|
502
|
-
userKeys.push(key)
|
|
503
|
-
}
|
|
504
|
-
expect(userKeys.sort()).toEqual(['email', 'name'])
|
|
505
|
-
})
|
|
506
|
-
|
|
507
|
-
test('in operator works', () => {
|
|
508
|
-
const user = createStore({ name: 'Alice' })
|
|
509
|
-
expect('name' in user).toBe(true)
|
|
510
|
-
expect('email' in user).toBe(false)
|
|
511
|
-
})
|
|
512
|
-
|
|
513
|
-
test('Object.getOwnPropertyDescriptor works', () => {
|
|
514
|
-
const user = createStore({ name: 'Alice' })
|
|
515
|
-
const nameDescriptor = Object.getOwnPropertyDescriptor(user, 'name')
|
|
516
|
-
expect(nameDescriptor).toEqual({
|
|
517
|
-
enumerable: true,
|
|
518
|
-
configurable: true,
|
|
519
|
-
writable: true,
|
|
520
|
-
value: user.name,
|
|
521
|
-
})
|
|
522
|
-
})
|
|
523
|
-
})
|
|
524
|
-
|
|
525
|
-
describe('byKey() method', () => {
|
|
526
|
-
test('works with property keys', () => {
|
|
527
|
-
const user = createStore({
|
|
528
|
-
name: 'Alice',
|
|
529
|
-
email: 'alice@example.com',
|
|
530
|
-
age: 30,
|
|
531
|
-
})
|
|
532
|
-
|
|
533
|
-
const nameSignal = user.byKey('name')
|
|
534
|
-
const emailSignal = user.byKey('email')
|
|
535
|
-
const ageSignal = user.byKey('age')
|
|
536
|
-
// @ts-expect-error deliberate access for nonexistent key
|
|
537
|
-
const nonexistentSignal = user.byKey('nonexistent')
|
|
538
|
-
|
|
539
|
-
expect(nameSignal?.get()).toBe('Alice')
|
|
540
|
-
expect(emailSignal?.get()).toBe('alice@example.com')
|
|
541
|
-
expect(ageSignal?.get()).toBe(30)
|
|
542
|
-
expect(nonexistentSignal).toBeUndefined()
|
|
543
|
-
|
|
544
|
-
// Verify these are the same signals as property access
|
|
545
|
-
expect(nameSignal).toBe(user.name)
|
|
546
|
-
expect(emailSignal).toBe(user.email)
|
|
547
|
-
expect(ageSignal).toBe(user.age)
|
|
548
|
-
})
|
|
549
|
-
|
|
550
|
-
test('works with nested stores', () => {
|
|
551
|
-
const app = createStore({
|
|
552
|
-
config: {
|
|
553
|
-
version: '1.0.0',
|
|
554
|
-
},
|
|
555
|
-
})
|
|
556
|
-
|
|
557
|
-
const configStore = app.byKey('config')
|
|
558
|
-
expect(configStore?.get()).toEqual({ version: '1.0.0' })
|
|
559
|
-
expect(configStore).toBe(app.config)
|
|
560
|
-
})
|
|
561
|
-
|
|
562
|
-
test('is reactive and works with computed signals', () => {
|
|
563
|
-
const user = createStore<{
|
|
564
|
-
name: string
|
|
565
|
-
age: number
|
|
566
|
-
}>({
|
|
567
|
-
name: 'Alice',
|
|
568
|
-
age: 30,
|
|
569
|
-
})
|
|
570
|
-
|
|
571
|
-
const nameSignal = user.byKey('name')
|
|
572
|
-
const displayName = new Memo(() =>
|
|
573
|
-
nameSignal ? `Hello, ${nameSignal.get()}!` : 'Unknown',
|
|
574
|
-
)
|
|
575
|
-
|
|
576
|
-
expect(displayName.get()).toBe('Hello, Alice!')
|
|
577
|
-
nameSignal?.set('Bob')
|
|
578
|
-
expect(displayName.get()).toBe('Hello, Bob!')
|
|
579
|
-
})
|
|
580
|
-
})
|
|
581
|
-
|
|
582
|
-
describe('UNSET and edge cases', () => {
|
|
583
|
-
test('handles UNSET values', () => {
|
|
584
|
-
const store = createStore({ value: UNSET })
|
|
585
|
-
expect(store.get()).toEqual({ value: UNSET })
|
|
586
|
-
})
|
|
587
|
-
|
|
588
|
-
test('handles primitive values', () => {
|
|
589
|
-
const store = createStore({
|
|
590
|
-
str: 'hello',
|
|
591
|
-
num: 42,
|
|
592
|
-
bool: true,
|
|
593
|
-
})
|
|
594
|
-
expect(store.str.get()).toBe('hello')
|
|
595
|
-
expect(store.num.get()).toBe(42)
|
|
596
|
-
expect(store.bool.get()).toBe(true)
|
|
597
|
-
})
|
|
598
|
-
|
|
599
|
-
test('handles empty stores correctly', () => {
|
|
600
|
-
const empty = createStore({})
|
|
601
|
-
expect(empty.get()).toEqual({})
|
|
602
|
-
})
|
|
603
|
-
})
|
|
604
|
-
|
|
605
|
-
describe('JSON integration and serialization', () => {
|
|
606
|
-
test('seamless JSON integration', () => {
|
|
607
|
-
const jsonData = {
|
|
439
|
+
describe('Serialization', () => {
|
|
440
|
+
test('should round-trip through JSON', () => {
|
|
441
|
+
const data = {
|
|
608
442
|
user: { name: 'Alice', preferences: { theme: 'dark' } },
|
|
609
443
|
settings: { timeout: 5000 },
|
|
610
444
|
}
|
|
611
|
-
const store = createStore(
|
|
612
|
-
|
|
613
|
-
expect(
|
|
614
|
-
expect(store.user.preferences.theme.get()).toBe('dark')
|
|
615
|
-
expect(store.settings.timeout.get()).toBe(5000)
|
|
616
|
-
|
|
617
|
-
const serialized = JSON.stringify(store.get())
|
|
618
|
-
const parsed = JSON.parse(serialized)
|
|
619
|
-
expect(parsed).toEqual(jsonData)
|
|
620
|
-
})
|
|
621
|
-
|
|
622
|
-
test('handles complex nested structures from JSON', () => {
|
|
623
|
-
type Dashboard = {
|
|
624
|
-
dashboard: {
|
|
625
|
-
widgets: Array<{
|
|
626
|
-
id: string
|
|
627
|
-
type: string
|
|
628
|
-
config: { color?: string; rows?: number }
|
|
629
|
-
}>
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
const complexData: Dashboard = {
|
|
634
|
-
dashboard: {
|
|
635
|
-
widgets: [
|
|
636
|
-
{ id: '1', type: 'chart', config: { color: 'blue' } },
|
|
637
|
-
{ id: '2', type: 'table', config: { rows: 10 } },
|
|
638
|
-
],
|
|
639
|
-
},
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
const store = createStore(complexData)
|
|
643
|
-
expect(store.dashboard.widgets.at(0)?.get().config.color).toBe(
|
|
644
|
-
'blue',
|
|
645
|
-
)
|
|
646
|
-
expect(store.dashboard.widgets.at(1)?.get().config.rows).toBe(10)
|
|
445
|
+
const store = createStore(data)
|
|
446
|
+
const parsed = JSON.parse(JSON.stringify(store.get()))
|
|
447
|
+
expect(parsed).toEqual(data)
|
|
647
448
|
})
|
|
648
449
|
})
|
|
649
450
|
|
|
650
|
-
describe('
|
|
651
|
-
test('
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
expect(config.database.host.get()).toBe('localhost')
|
|
661
|
-
expect(config.database.port.get()).toBe(5432)
|
|
662
|
-
})
|
|
663
|
-
})
|
|
664
|
-
|
|
665
|
-
describe('HOOK_WATCH - Store Hierarchy Resource Management', () => {
|
|
666
|
-
test('Store HOOK_WATCH triggers for all nested stores when accessing parent', async () => {
|
|
667
|
-
const store = createStore({
|
|
668
|
-
app: {
|
|
669
|
-
database: {
|
|
670
|
-
host: 'localhost',
|
|
671
|
-
port: 5432,
|
|
672
|
-
},
|
|
673
|
-
cache: {
|
|
674
|
-
ttl: 3600,
|
|
675
|
-
},
|
|
676
|
-
},
|
|
677
|
-
})
|
|
678
|
-
|
|
679
|
-
let appCounter = 0
|
|
680
|
-
let databaseCounter = 0
|
|
681
|
-
let cacheCounter = 0
|
|
682
|
-
|
|
683
|
-
const appCleanup = store.app.on(HOOK_WATCH, () => {
|
|
684
|
-
appCounter++
|
|
685
|
-
return () => {
|
|
686
|
-
appCounter--
|
|
687
|
-
}
|
|
688
|
-
})
|
|
689
|
-
|
|
690
|
-
const databaseCleanup = store.app.database.on(HOOK_WATCH, () => {
|
|
691
|
-
databaseCounter++
|
|
692
|
-
return () => {
|
|
693
|
-
databaseCounter--
|
|
694
|
-
}
|
|
695
|
-
})
|
|
696
|
-
|
|
697
|
-
const cacheCleanup = store.app.cache.on(HOOK_WATCH, () => {
|
|
698
|
-
cacheCounter++
|
|
699
|
-
return () => {
|
|
700
|
-
cacheCounter--
|
|
701
|
-
}
|
|
702
|
-
})
|
|
703
|
-
|
|
704
|
-
// Initially no watchers
|
|
705
|
-
expect(appCounter).toBe(0)
|
|
706
|
-
expect(databaseCounter).toBe(0)
|
|
707
|
-
expect(cacheCounter).toBe(0)
|
|
708
|
-
|
|
709
|
-
// Access app store - should trigger ALL nested HOOK_WATCH callbacks
|
|
710
|
-
const appEffect = createEffect(() => {
|
|
711
|
-
store.app.get()
|
|
712
|
-
})
|
|
713
|
-
|
|
714
|
-
expect(appCounter).toBe(1)
|
|
715
|
-
expect(databaseCounter).toBe(1)
|
|
716
|
-
expect(cacheCounter).toBe(1)
|
|
717
|
-
|
|
718
|
-
// Cleanup should reset all counters
|
|
719
|
-
appEffect()
|
|
720
|
-
expect(appCounter).toBe(0)
|
|
721
|
-
expect(databaseCounter).toBe(0)
|
|
722
|
-
expect(cacheCounter).toBe(0)
|
|
723
|
-
|
|
724
|
-
appCleanup()
|
|
725
|
-
databaseCleanup()
|
|
726
|
-
cacheCleanup()
|
|
727
|
-
})
|
|
728
|
-
|
|
729
|
-
test('Nested store cleanup only happens when all levels are unwatched', async () => {
|
|
730
|
-
const store = createStore({
|
|
731
|
-
user: {
|
|
732
|
-
profile: {
|
|
733
|
-
settings: {
|
|
734
|
-
theme: 'dark',
|
|
735
|
-
},
|
|
736
|
-
},
|
|
737
|
-
},
|
|
738
|
-
})
|
|
739
|
-
|
|
740
|
-
let counter = 0
|
|
741
|
-
let intervalId: Timer | undefined
|
|
742
|
-
|
|
743
|
-
// Add HOOK_WATCH to deepest nested store
|
|
744
|
-
const settingsCleanup = store.user.profile.settings.on(
|
|
745
|
-
HOOK_WATCH,
|
|
746
|
-
() => {
|
|
747
|
-
intervalId = setInterval(() => {
|
|
748
|
-
counter++
|
|
749
|
-
}, 10)
|
|
750
|
-
|
|
751
|
-
return () => {
|
|
752
|
-
if (intervalId) {
|
|
753
|
-
clearInterval(intervalId)
|
|
754
|
-
intervalId = undefined
|
|
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--
|
|
755
461
|
}
|
|
756
|
-
}
|
|
462
|
+
},
|
|
757
463
|
},
|
|
758
464
|
)
|
|
465
|
+
expect(watchCount).toBe(0)
|
|
759
466
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
// Access parent store - should trigger settings HOOK_WATCH
|
|
763
|
-
const parentEffect = createEffect(() => {
|
|
764
|
-
store.user.get()
|
|
467
|
+
const cleanup1 = createEffect(() => {
|
|
468
|
+
store.get()
|
|
765
469
|
})
|
|
470
|
+
expect(watchCount).toBe(1)
|
|
766
471
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
expect(intervalId).toBeDefined()
|
|
770
|
-
|
|
771
|
-
// Access intermediate store - settings should still be active
|
|
772
|
-
const profileEffect = createEffect(() => {
|
|
773
|
-
store.user.profile.get()
|
|
472
|
+
const cleanup2 = createEffect(() => {
|
|
473
|
+
store.get()
|
|
774
474
|
})
|
|
475
|
+
expect(watchCount).toBe(1) // still 1
|
|
775
476
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
expect(counter).toBeGreaterThan(counterAfterProfile)
|
|
779
|
-
expect(intervalId).toBeDefined()
|
|
780
|
-
|
|
781
|
-
// Remove parent watcher, but profile watcher still active
|
|
782
|
-
parentEffect()
|
|
783
|
-
|
|
784
|
-
const counterAfterParentRemoval = counter
|
|
785
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
786
|
-
expect(counter).toBeGreaterThan(counterAfterParentRemoval)
|
|
787
|
-
expect(intervalId).toBeDefined() // Still running
|
|
788
|
-
|
|
789
|
-
// Remove profile watcher - now should cleanup
|
|
790
|
-
profileEffect()
|
|
791
|
-
|
|
792
|
-
const counterAfterAllRemoval = counter
|
|
793
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
794
|
-
expect(counter).toBe(counterAfterAllRemoval) // Stopped
|
|
795
|
-
expect(intervalId).toBeUndefined()
|
|
796
|
-
|
|
797
|
-
settingsCleanup()
|
|
798
|
-
})
|
|
477
|
+
cleanup2()
|
|
478
|
+
expect(watchCount).toBe(1) // still active
|
|
799
479
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
480
|
+
cleanup1()
|
|
481
|
+
expect(watchCount).toBe(0) // cleaned up
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
test('should not activate for nested property access only', async () => {
|
|
485
|
+
let activated = false
|
|
486
|
+
const store = createStore(
|
|
487
|
+
{ user: { name: 'John' } },
|
|
488
|
+
{
|
|
489
|
+
watched: () => {
|
|
490
|
+
activated = true
|
|
491
|
+
return () => {
|
|
492
|
+
activated = false
|
|
493
|
+
}
|
|
806
494
|
},
|
|
807
495
|
},
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
let rootStoreCounter = 0
|
|
811
|
-
let intervalId: Timer | undefined
|
|
812
|
-
|
|
813
|
-
// Add HOOK_WATCH callback to root store
|
|
814
|
-
const cleanupHookCallback = store.on(HOOK_WATCH, () => {
|
|
815
|
-
intervalId = setInterval(() => {
|
|
816
|
-
rootStoreCounter++
|
|
817
|
-
}, 10)
|
|
818
|
-
|
|
819
|
-
return () => {
|
|
820
|
-
if (intervalId) {
|
|
821
|
-
clearInterval(intervalId)
|
|
822
|
-
intervalId = undefined
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
})
|
|
826
|
-
|
|
827
|
-
expect(rootStoreCounter).toBe(0)
|
|
828
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
829
|
-
expect(rootStoreCounter).toBe(0)
|
|
496
|
+
)
|
|
830
497
|
|
|
831
|
-
|
|
832
|
-
const nestedEffectCleanup = createEffect(() => {
|
|
498
|
+
const cleanup = createEffect(() => {
|
|
833
499
|
store.user.name.get()
|
|
834
500
|
})
|
|
501
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
502
|
+
expect(activated).toBe(false)
|
|
835
503
|
|
|
836
|
-
|
|
837
|
-
expect(rootStoreCounter).toBe(0) // Still 0 - nested access doesn't trigger root
|
|
838
|
-
expect(intervalId).toBeUndefined()
|
|
839
|
-
|
|
840
|
-
// Access root store directly - should trigger HOOK_WATCH
|
|
841
|
-
const rootEffectCleanup = createEffect(() => {
|
|
842
|
-
store.get()
|
|
843
|
-
})
|
|
844
|
-
|
|
845
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
846
|
-
expect(rootStoreCounter).toBeGreaterThan(0) // Now triggered
|
|
847
|
-
expect(intervalId).toBeDefined()
|
|
848
|
-
|
|
849
|
-
// Cleanup
|
|
850
|
-
rootEffectCleanup()
|
|
851
|
-
nestedEffectCleanup()
|
|
852
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
853
|
-
expect(intervalId).toBeUndefined()
|
|
854
|
-
|
|
855
|
-
cleanupHookCallback()
|
|
504
|
+
cleanup()
|
|
856
505
|
})
|
|
857
506
|
|
|
858
|
-
test('
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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 },
|
|
511
|
+
{
|
|
512
|
+
watched: () => {
|
|
513
|
+
watchCount++
|
|
514
|
+
return () => {
|
|
515
|
+
watchCount--
|
|
516
|
+
}
|
|
864
517
|
},
|
|
865
518
|
},
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
let rootCounter = 0
|
|
869
|
-
let configCounter = 0
|
|
870
|
-
let databaseCounter = 0
|
|
871
|
-
|
|
872
|
-
// Add HOOK_WATCH to each level
|
|
873
|
-
const rootCleanup = store.on(HOOK_WATCH, () => {
|
|
874
|
-
rootCounter++
|
|
875
|
-
return () => {
|
|
876
|
-
rootCounter--
|
|
877
|
-
}
|
|
878
|
-
})
|
|
879
|
-
|
|
880
|
-
const configCleanup = store.config.on(HOOK_WATCH, () => {
|
|
881
|
-
configCounter++
|
|
882
|
-
return () => {
|
|
883
|
-
configCounter--
|
|
884
|
-
}
|
|
885
|
-
})
|
|
886
|
-
|
|
887
|
-
const databaseCleanup = store.config.database.on(HOOK_WATCH, () => {
|
|
888
|
-
databaseCounter++
|
|
889
|
-
return () => {
|
|
890
|
-
databaseCounter--
|
|
891
|
-
}
|
|
892
|
-
})
|
|
893
|
-
|
|
894
|
-
// All should start at 0
|
|
895
|
-
expect(rootCounter).toBe(0)
|
|
896
|
-
expect(configCounter).toBe(0)
|
|
897
|
-
expect(databaseCounter).toBe(0)
|
|
898
|
-
|
|
899
|
-
// Access deepest level - should NOT trigger any store HOOK_WATCH
|
|
900
|
-
// because we're only accessing the State signal, not calling .get() on stores
|
|
901
|
-
const deepEffectCleanup = createEffect(() => {
|
|
902
|
-
store.config.database.host.get()
|
|
903
|
-
})
|
|
904
|
-
|
|
905
|
-
expect(rootCounter).toBe(0)
|
|
906
|
-
expect(configCounter).toBe(0)
|
|
907
|
-
expect(databaseCounter).toBe(0)
|
|
908
|
-
|
|
909
|
-
// Access config level - should trigger config AND database HOOK_WATCH
|
|
910
|
-
const configEffectCleanup = createEffect(() => {
|
|
911
|
-
store.config.get()
|
|
912
|
-
})
|
|
913
|
-
|
|
914
|
-
expect(rootCounter).toBe(0)
|
|
915
|
-
expect(configCounter).toBe(1)
|
|
916
|
-
expect(databaseCounter).toBe(1) // Triggered by parent access
|
|
519
|
+
)
|
|
520
|
+
expect(watchCount).toBe(0)
|
|
917
521
|
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
store.get()
|
|
522
|
+
const cleanup = createEffect(() => {
|
|
523
|
+
Array.from(store.keys()).forEach(() => {})
|
|
921
524
|
})
|
|
525
|
+
expect(watchCount).toBe(1)
|
|
922
526
|
|
|
923
|
-
|
|
924
|
-
expect(
|
|
925
|
-
expect(databaseCounter).toBe(1)
|
|
926
|
-
|
|
927
|
-
// Cleanup in reverse order - database should stay active until config is cleaned up
|
|
928
|
-
rootEffectCleanup()
|
|
929
|
-
expect(rootCounter).toBe(0)
|
|
930
|
-
expect(configCounter).toBe(1)
|
|
931
|
-
expect(databaseCounter).toBe(1) // Still active due to config watcher
|
|
932
|
-
|
|
933
|
-
configEffectCleanup()
|
|
934
|
-
expect(rootCounter).toBe(0)
|
|
935
|
-
expect(configCounter).toBe(0)
|
|
936
|
-
expect(databaseCounter).toBe(0) // Now cleaned up
|
|
937
|
-
|
|
938
|
-
deepEffectCleanup()
|
|
939
|
-
expect(rootCounter).toBe(0)
|
|
940
|
-
expect(configCounter).toBe(0)
|
|
941
|
-
expect(databaseCounter).toBe(0)
|
|
942
|
-
|
|
943
|
-
// Cleanup hooks
|
|
944
|
-
rootCleanup()
|
|
945
|
-
configCleanup()
|
|
946
|
-
databaseCleanup()
|
|
527
|
+
cleanup()
|
|
528
|
+
expect(watchCount).toBe(0)
|
|
947
529
|
})
|
|
530
|
+
})
|
|
948
531
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
count: 0,
|
|
954
|
-
},
|
|
955
|
-
})
|
|
956
|
-
|
|
957
|
-
let dataStoreCounter = 0
|
|
958
|
-
|
|
959
|
-
const dataCleanup = store.data.on(HOOK_WATCH, () => {
|
|
960
|
-
dataStoreCounter++
|
|
961
|
-
return () => {
|
|
962
|
-
dataStoreCounter--
|
|
963
|
-
}
|
|
964
|
-
})
|
|
965
|
-
|
|
966
|
-
expect(dataStoreCounter).toBe(0)
|
|
967
|
-
|
|
968
|
-
// Create multiple effects watching the data store
|
|
969
|
-
const effect1 = createEffect(() => {
|
|
970
|
-
store.data.get()
|
|
971
|
-
})
|
|
972
|
-
const effect2 = createEffect(() => {
|
|
973
|
-
store.data.get()
|
|
974
|
-
})
|
|
975
|
-
|
|
976
|
-
// Should only trigger once (shared resources)
|
|
977
|
-
expect(dataStoreCounter).toBe(1)
|
|
978
|
-
|
|
979
|
-
// Stop one effect
|
|
980
|
-
effect1()
|
|
981
|
-
expect(dataStoreCounter).toBe(1) // Still active
|
|
982
|
-
|
|
983
|
-
// Stop second effect
|
|
984
|
-
effect2()
|
|
985
|
-
expect(dataStoreCounter).toBe(0) // Now cleaned up
|
|
986
|
-
|
|
987
|
-
dataCleanup()
|
|
532
|
+
describe('Input Validation', () => {
|
|
533
|
+
test('should throw for null initial value', () => {
|
|
534
|
+
// @ts-expect-error testing null
|
|
535
|
+
expect(() => createStore(null)).toThrow()
|
|
988
536
|
})
|
|
989
537
|
|
|
990
|
-
test('
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
})
|
|
994
|
-
|
|
995
|
-
let usersStoreCounter = 0
|
|
996
|
-
|
|
997
|
-
const usersCleanup = store.users.on(HOOK_WATCH, () => {
|
|
998
|
-
usersStoreCounter++
|
|
999
|
-
return () => {
|
|
1000
|
-
usersStoreCounter--
|
|
1001
|
-
}
|
|
1002
|
-
})
|
|
1003
|
-
|
|
1004
|
-
expect(usersStoreCounter).toBe(0)
|
|
1005
|
-
|
|
1006
|
-
// Watch the users store
|
|
1007
|
-
const usersEffect = createEffect(() => {
|
|
1008
|
-
store.users.get()
|
|
1009
|
-
})
|
|
1010
|
-
expect(usersStoreCounter).toBe(1)
|
|
1011
|
-
|
|
1012
|
-
// Add a user - this modifies the users store content but doesn't affect HOOK_WATCH
|
|
1013
|
-
store.users.add('user1', { name: 'Alice' })
|
|
1014
|
-
expect(usersStoreCounter).toBe(1) // Still 1
|
|
1015
|
-
|
|
1016
|
-
// Watch a specific user property - this doesn't trigger users store HOOK_WATCH
|
|
1017
|
-
const userEffect = createEffect(() => {
|
|
1018
|
-
store.users.user1?.name.get()
|
|
1019
|
-
})
|
|
1020
|
-
expect(usersStoreCounter).toBe(1) // Still 1
|
|
1021
|
-
|
|
1022
|
-
// Cleanup user effect
|
|
1023
|
-
userEffect()
|
|
1024
|
-
expect(usersStoreCounter).toBe(1) // Still active due to usersEffect
|
|
1025
|
-
|
|
1026
|
-
// Cleanup users effect
|
|
1027
|
-
usersEffect()
|
|
1028
|
-
expect(usersStoreCounter).toBe(0) // Now cleaned up
|
|
1029
|
-
|
|
1030
|
-
usersCleanup()
|
|
538
|
+
test('should throw for undefined initial value', () => {
|
|
539
|
+
// @ts-expect-error testing undefined
|
|
540
|
+
expect(() => createStore(undefined)).toThrow()
|
|
1031
541
|
})
|
|
1032
542
|
|
|
1033
|
-
test('
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
})
|
|
1037
|
-
|
|
1038
|
-
let successfulCallbackCalled = false
|
|
1039
|
-
let throwingCallbackCalled = false
|
|
1040
|
-
|
|
1041
|
-
// Add throwing callback
|
|
1042
|
-
const cleanup1 = store.on(HOOK_WATCH, () => {
|
|
1043
|
-
throwingCallbackCalled = true
|
|
1044
|
-
throw new Error('Test error in store HOOK_WATCH')
|
|
1045
|
-
})
|
|
1046
|
-
|
|
1047
|
-
// Add successful callback
|
|
1048
|
-
const cleanup2 = store.on(HOOK_WATCH, () => {
|
|
1049
|
-
successfulCallbackCalled = true
|
|
1050
|
-
return () => {
|
|
1051
|
-
// cleanup
|
|
1052
|
-
}
|
|
1053
|
-
})
|
|
1054
|
-
|
|
1055
|
-
// Trigger callbacks through direct store access - should throw
|
|
1056
|
-
expect(() => store.get()).toThrow('Test error in store HOOK_WATCH')
|
|
1057
|
-
|
|
1058
|
-
// Both callbacks should have been called
|
|
1059
|
-
expect(throwingCallbackCalled).toBe(true)
|
|
1060
|
-
expect(successfulCallbackCalled).toBe(true)
|
|
1061
|
-
|
|
1062
|
-
cleanup1()
|
|
1063
|
-
cleanup2()
|
|
543
|
+
test('should throw for non-object initial value', () => {
|
|
544
|
+
// @ts-expect-error testing primitive
|
|
545
|
+
expect(() => createStore('hello')).toThrow()
|
|
1064
546
|
})
|
|
1065
547
|
|
|
1066
|
-
test('
|
|
1067
|
-
const store = createStore({
|
|
1068
|
-
|
|
1069
|
-
firstName: 'John',
|
|
1070
|
-
lastName: 'Doe',
|
|
1071
|
-
},
|
|
1072
|
-
})
|
|
1073
|
-
|
|
1074
|
-
let userStoreCounter = 0
|
|
1075
|
-
|
|
1076
|
-
const userCleanup = store.user.on(HOOK_WATCH, () => {
|
|
1077
|
-
userStoreCounter++
|
|
1078
|
-
return () => {
|
|
1079
|
-
userStoreCounter--
|
|
1080
|
-
}
|
|
1081
|
-
})
|
|
1082
|
-
|
|
1083
|
-
expect(userStoreCounter).toBe(0)
|
|
1084
|
-
|
|
1085
|
-
// Access user store directly - should trigger user store HOOK_WATCH
|
|
1086
|
-
const userEffect = createEffect(() => {
|
|
1087
|
-
store.user.get()
|
|
1088
|
-
})
|
|
1089
|
-
expect(userStoreCounter).toBe(1)
|
|
1090
|
-
|
|
1091
|
-
// Access individual properties - should NOT trigger user store HOOK_WATCH again
|
|
1092
|
-
const nameEffect = createEffect(() => {
|
|
1093
|
-
store.user.firstName.get()
|
|
548
|
+
test('should throw for null value in add()', () => {
|
|
549
|
+
const store = createStore<{ name: string; email?: string }>({
|
|
550
|
+
name: 'John',
|
|
1094
551
|
})
|
|
1095
|
-
expect
|
|
1096
|
-
|
|
1097
|
-
// Cleanup individual property effect first
|
|
1098
|
-
nameEffect()
|
|
1099
|
-
expect(userStoreCounter).toBe(1) // Still active due to user store effect
|
|
1100
|
-
|
|
1101
|
-
// Cleanup user store effect - now should be cleaned up
|
|
1102
|
-
userEffect()
|
|
1103
|
-
expect(userStoreCounter).toBe(0) // Now cleaned up
|
|
1104
|
-
|
|
1105
|
-
userCleanup()
|
|
552
|
+
// @ts-expect-error testing null
|
|
553
|
+
expect(() => store.add('email', null)).toThrow()
|
|
1106
554
|
})
|
|
1107
555
|
})
|
|
1108
556
|
})
|