@pyreon/reactivity 0.11.4 → 0.11.6
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/README.md +2 -2
- package/lib/index.js.map +1 -1
- package/package.json +10 -10
- package/src/computed.ts +5 -5
- package/src/createSelector.ts +2 -2
- package/src/debug.ts +8 -8
- package/src/effect.ts +4 -4
- package/src/index.ts +13 -13
- package/src/reconcile.ts +4 -4
- package/src/resource.ts +4 -4
- package/src/scope.ts +1 -1
- package/src/signal.ts +7 -7
- package/src/store.ts +9 -9
- package/src/tests/batch.test.ts +16 -16
- package/src/tests/bind.test.ts +8 -8
- package/src/tests/branches.test.ts +66 -66
- package/src/tests/cell.test.ts +22 -22
- package/src/tests/computed.test.ts +24 -24
- package/src/tests/createSelector.test.ts +17 -17
- package/src/tests/debug.test.ts +29 -29
- package/src/tests/effect.test.ts +59 -59
- package/src/tests/resource.test.ts +39 -39
- package/src/tests/scope.test.ts +20 -20
- package/src/tests/signal.test.ts +39 -39
- package/src/tests/store.test.ts +64 -64
- package/src/tests/tracking.test.ts +17 -17
- package/src/tests/watch.test.ts +32 -32
- package/src/tracking.ts +1 -1
- package/src/watch.ts +3 -3
package/src/tests/scope.test.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import { effect } from
|
|
2
|
-
import { EffectScope, effectScope, getCurrentScope, setCurrentScope } from
|
|
3
|
-
import { signal } from
|
|
1
|
+
import { effect } from '../effect'
|
|
2
|
+
import { EffectScope, effectScope, getCurrentScope, setCurrentScope } from '../scope'
|
|
3
|
+
import { signal } from '../signal'
|
|
4
4
|
|
|
5
|
-
describe(
|
|
6
|
-
test(
|
|
5
|
+
describe('effectScope', () => {
|
|
6
|
+
test('creates an EffectScope instance', () => {
|
|
7
7
|
const scope = effectScope()
|
|
8
8
|
expect(scope).toBeInstanceOf(EffectScope)
|
|
9
9
|
})
|
|
10
10
|
|
|
11
|
-
test(
|
|
11
|
+
test('getCurrentScope returns null by default', () => {
|
|
12
12
|
expect(getCurrentScope()).toBeNull()
|
|
13
13
|
})
|
|
14
14
|
|
|
15
|
-
test(
|
|
15
|
+
test('setCurrentScope sets and clears the current scope', () => {
|
|
16
16
|
const scope = effectScope()
|
|
17
17
|
setCurrentScope(scope)
|
|
18
18
|
expect(getCurrentScope()).toBe(scope)
|
|
@@ -20,7 +20,7 @@ describe("effectScope", () => {
|
|
|
20
20
|
expect(getCurrentScope()).toBeNull()
|
|
21
21
|
})
|
|
22
22
|
|
|
23
|
-
test(
|
|
23
|
+
test('effects created within a scope are disposed on stop', () => {
|
|
24
24
|
const scope = effectScope()
|
|
25
25
|
setCurrentScope(scope)
|
|
26
26
|
|
|
@@ -42,7 +42,7 @@ describe("effectScope", () => {
|
|
|
42
42
|
expect(count).toBe(2) // effect disposed, no re-run
|
|
43
43
|
})
|
|
44
44
|
|
|
45
|
-
test(
|
|
45
|
+
test('stop is idempotent — second call does nothing', () => {
|
|
46
46
|
const scope = effectScope()
|
|
47
47
|
setCurrentScope(scope)
|
|
48
48
|
|
|
@@ -60,14 +60,14 @@ describe("effectScope", () => {
|
|
|
60
60
|
expect(count).toBe(1)
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
-
test(
|
|
63
|
+
test('add is ignored after scope is stopped', () => {
|
|
64
64
|
const scope = effectScope()
|
|
65
65
|
scope.stop()
|
|
66
66
|
// Should not throw — add is silently ignored
|
|
67
67
|
scope.add({ dispose() {} })
|
|
68
68
|
})
|
|
69
69
|
|
|
70
|
-
test(
|
|
70
|
+
test('runInScope temporarily re-activates the scope', () => {
|
|
71
71
|
const scope = effectScope()
|
|
72
72
|
setCurrentScope(null)
|
|
73
73
|
|
|
@@ -91,7 +91,7 @@ describe("effectScope", () => {
|
|
|
91
91
|
expect(count).toBe(2) // disposed via scope
|
|
92
92
|
})
|
|
93
93
|
|
|
94
|
-
test(
|
|
94
|
+
test('runInScope restores previous scope even on error', () => {
|
|
95
95
|
const scope = effectScope()
|
|
96
96
|
const prevScope = effectScope()
|
|
97
97
|
setCurrentScope(prevScope)
|
|
@@ -99,7 +99,7 @@ describe("effectScope", () => {
|
|
|
99
99
|
try {
|
|
100
100
|
scope.runInScope(() => {
|
|
101
101
|
expect(getCurrentScope()).toBe(scope)
|
|
102
|
-
throw new Error(
|
|
102
|
+
throw new Error('test')
|
|
103
103
|
})
|
|
104
104
|
} catch {
|
|
105
105
|
// expected
|
|
@@ -115,7 +115,7 @@ describe("effectScope", () => {
|
|
|
115
115
|
expect(result).toBe(42)
|
|
116
116
|
})
|
|
117
117
|
|
|
118
|
-
test(
|
|
118
|
+
test('addUpdateHook + notifyEffectRan fires hooks via microtask', async () => {
|
|
119
119
|
const scope = effectScope()
|
|
120
120
|
let hookCalled = 0
|
|
121
121
|
|
|
@@ -130,14 +130,14 @@ describe("effectScope", () => {
|
|
|
130
130
|
expect(hookCalled).toBe(1)
|
|
131
131
|
})
|
|
132
132
|
|
|
133
|
-
test(
|
|
133
|
+
test('notifyEffectRan does nothing when no update hooks', async () => {
|
|
134
134
|
const scope = effectScope()
|
|
135
135
|
// Should not throw — early return when _updateHooks is empty
|
|
136
136
|
scope.notifyEffectRan()
|
|
137
137
|
await new Promise((r) => setTimeout(r, 10))
|
|
138
138
|
})
|
|
139
139
|
|
|
140
|
-
test(
|
|
140
|
+
test('notifyEffectRan does nothing after scope is stopped', async () => {
|
|
141
141
|
const scope = effectScope()
|
|
142
142
|
let hookCalled = 0
|
|
143
143
|
|
|
@@ -152,7 +152,7 @@ describe("effectScope", () => {
|
|
|
152
152
|
expect(hookCalled).toBe(0)
|
|
153
153
|
})
|
|
154
154
|
|
|
155
|
-
test(
|
|
155
|
+
test('notifyEffectRan deduplicates — only one microtask while pending', async () => {
|
|
156
156
|
const scope = effectScope()
|
|
157
157
|
let hookCalled = 0
|
|
158
158
|
|
|
@@ -168,7 +168,7 @@ describe("effectScope", () => {
|
|
|
168
168
|
expect(hookCalled).toBe(1) // only fired once
|
|
169
169
|
})
|
|
170
170
|
|
|
171
|
-
test(
|
|
171
|
+
test('notifyEffectRan skips hooks if scope stopped before microtask fires', async () => {
|
|
172
172
|
const scope = effectScope()
|
|
173
173
|
let hookCalled = 0
|
|
174
174
|
|
|
@@ -183,14 +183,14 @@ describe("effectScope", () => {
|
|
|
183
183
|
expect(hookCalled).toBe(0)
|
|
184
184
|
})
|
|
185
185
|
|
|
186
|
-
test(
|
|
186
|
+
test('onUpdate hook errors are caught and logged', async () => {
|
|
187
187
|
const scope = effectScope()
|
|
188
188
|
const errors: unknown[] = []
|
|
189
189
|
const origError = console.error
|
|
190
190
|
console.error = (...args: unknown[]) => errors.push(args)
|
|
191
191
|
|
|
192
192
|
scope.addUpdateHook(() => {
|
|
193
|
-
throw new Error(
|
|
193
|
+
throw new Error('hook error')
|
|
194
194
|
})
|
|
195
195
|
|
|
196
196
|
scope.notifyEffectRan()
|
package/src/tests/signal.test.ts
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
import { batch } from
|
|
2
|
-
import { effect } from
|
|
3
|
-
import { signal } from
|
|
1
|
+
import { batch } from '../batch'
|
|
2
|
+
import { effect } from '../effect'
|
|
3
|
+
import { signal } from '../signal'
|
|
4
4
|
|
|
5
|
-
describe(
|
|
6
|
-
test(
|
|
5
|
+
describe('signal', () => {
|
|
6
|
+
test('reads initial value', () => {
|
|
7
7
|
const s = signal(42)
|
|
8
8
|
expect(s()).toBe(42)
|
|
9
9
|
})
|
|
10
10
|
|
|
11
|
-
test(
|
|
11
|
+
test('set updates value', () => {
|
|
12
12
|
const s = signal(0)
|
|
13
13
|
s.set(10)
|
|
14
14
|
expect(s()).toBe(10)
|
|
15
15
|
})
|
|
16
16
|
|
|
17
|
-
test(
|
|
17
|
+
test('update transforms value', () => {
|
|
18
18
|
const s = signal(5)
|
|
19
19
|
s.update((n) => n * 2)
|
|
20
20
|
expect(s()).toBe(10)
|
|
21
21
|
})
|
|
22
22
|
|
|
23
|
-
test(
|
|
23
|
+
test('set with same value does not notify', () => {
|
|
24
24
|
const s = signal(1)
|
|
25
25
|
let calls = 0
|
|
26
26
|
effect(() => {
|
|
@@ -34,20 +34,20 @@ describe("signal", () => {
|
|
|
34
34
|
expect(calls).toBe(2)
|
|
35
35
|
})
|
|
36
36
|
|
|
37
|
-
test(
|
|
37
|
+
test('works with objects', () => {
|
|
38
38
|
const s = signal({ x: 1 })
|
|
39
39
|
s.update((o) => ({ ...o, x: 2 }))
|
|
40
40
|
expect(s().x).toBe(2)
|
|
41
41
|
})
|
|
42
42
|
|
|
43
|
-
test(
|
|
43
|
+
test('works with null and undefined', () => {
|
|
44
44
|
const s = signal<string | null>(null)
|
|
45
45
|
expect(s()).toBeNull()
|
|
46
|
-
s.set(
|
|
47
|
-
expect(s()).toBe(
|
|
46
|
+
s.set('hello')
|
|
47
|
+
expect(s()).toBe('hello')
|
|
48
48
|
})
|
|
49
49
|
|
|
50
|
-
test(
|
|
50
|
+
test('peek reads value without tracking', () => {
|
|
51
51
|
const s = signal(42)
|
|
52
52
|
let count = 0
|
|
53
53
|
effect(() => {
|
|
@@ -60,7 +60,7 @@ describe("signal", () => {
|
|
|
60
60
|
expect(s.peek()).toBe(100)
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
-
test(
|
|
63
|
+
test('subscribe adds a static listener', () => {
|
|
64
64
|
const s = signal(0)
|
|
65
65
|
let notified = 0
|
|
66
66
|
const unsub = s.subscribe(() => {
|
|
@@ -77,34 +77,34 @@ describe("signal", () => {
|
|
|
77
77
|
expect(notified).toBe(2) // unsubscribed
|
|
78
78
|
})
|
|
79
79
|
|
|
80
|
-
test(
|
|
80
|
+
test('subscribe disposer is safe to call multiple times', () => {
|
|
81
81
|
const s = signal(0)
|
|
82
82
|
const unsub = s.subscribe(() => {})
|
|
83
83
|
unsub()
|
|
84
84
|
unsub() // should not throw
|
|
85
85
|
})
|
|
86
86
|
|
|
87
|
-
test(
|
|
88
|
-
const s = signal(0, { name:
|
|
89
|
-
expect(s.label).toBe(
|
|
87
|
+
test('label getter returns name from options', () => {
|
|
88
|
+
const s = signal(0, { name: 'counter' })
|
|
89
|
+
expect(s.label).toBe('counter')
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
-
test(
|
|
92
|
+
test('label setter updates the name', () => {
|
|
93
93
|
const s = signal(0)
|
|
94
94
|
expect(s.label).toBeUndefined()
|
|
95
|
-
s.label =
|
|
96
|
-
expect(s.label).toBe(
|
|
95
|
+
s.label = 'renamed'
|
|
96
|
+
expect(s.label).toBe('renamed')
|
|
97
97
|
})
|
|
98
98
|
|
|
99
|
-
test(
|
|
100
|
-
const s = signal(42, { name:
|
|
99
|
+
test('debug() returns signal info', () => {
|
|
100
|
+
const s = signal(42, { name: 'test' })
|
|
101
101
|
const info = s.debug()
|
|
102
|
-
expect(info.name).toBe(
|
|
102
|
+
expect(info.name).toBe('test')
|
|
103
103
|
expect(info.value).toBe(42)
|
|
104
104
|
expect(info.subscriberCount).toBe(0)
|
|
105
105
|
})
|
|
106
106
|
|
|
107
|
-
test(
|
|
107
|
+
test('debug() reports subscriber count', () => {
|
|
108
108
|
const s = signal(0)
|
|
109
109
|
s.subscribe(() => {})
|
|
110
110
|
s.subscribe(() => {})
|
|
@@ -112,15 +112,15 @@ describe("signal", () => {
|
|
|
112
112
|
expect(info.subscriberCount).toBe(2)
|
|
113
113
|
})
|
|
114
114
|
|
|
115
|
-
test(
|
|
115
|
+
test('signal without options has undefined name', () => {
|
|
116
116
|
const s = signal(0)
|
|
117
117
|
expect(s.label).toBeUndefined()
|
|
118
118
|
const info = s.debug()
|
|
119
119
|
expect(info.name).toBeUndefined()
|
|
120
120
|
})
|
|
121
121
|
|
|
122
|
-
describe(
|
|
123
|
-
test(
|
|
122
|
+
describe('direct updater disposal', () => {
|
|
123
|
+
test('disposed direct updater is not called on subsequent updates', () => {
|
|
124
124
|
const s = signal(0)
|
|
125
125
|
let called = 0
|
|
126
126
|
const dispose = s.direct(() => {
|
|
@@ -135,7 +135,7 @@ describe("signal", () => {
|
|
|
135
135
|
expect(called).toBe(1) // not called after disposal
|
|
136
136
|
})
|
|
137
137
|
|
|
138
|
-
test(
|
|
138
|
+
test('multiple direct updaters, dispose one, others still fire', () => {
|
|
139
139
|
const s = signal(0)
|
|
140
140
|
let calls1 = 0
|
|
141
141
|
let calls2 = 0
|
|
@@ -163,22 +163,22 @@ describe("signal", () => {
|
|
|
163
163
|
expect(calls3).toBe(2) // still active
|
|
164
164
|
})
|
|
165
165
|
|
|
166
|
-
test(
|
|
166
|
+
test('direct updater slot is null after disposal', () => {
|
|
167
167
|
const s = signal(0)
|
|
168
168
|
const dispose = s.direct(() => {})
|
|
169
169
|
|
|
170
170
|
// Access internal _d array via cast
|
|
171
171
|
const internal = s as unknown as { _d: ((() => void) | null)[] | null }
|
|
172
172
|
expect(internal._d).not.toBeNull()
|
|
173
|
-
expect(internal._d![0]).toBeTypeOf(
|
|
173
|
+
expect(internal._d![0]).toBeTypeOf('function')
|
|
174
174
|
|
|
175
175
|
dispose()
|
|
176
176
|
expect(internal._d![0]).toBeNull()
|
|
177
177
|
})
|
|
178
178
|
})
|
|
179
179
|
|
|
180
|
-
describe(
|
|
181
|
-
test(
|
|
180
|
+
describe('signal.direct() for template binding', () => {
|
|
181
|
+
test('direct updater is called synchronously on signal change', () => {
|
|
182
182
|
const s = signal(0)
|
|
183
183
|
const values: number[] = []
|
|
184
184
|
s.direct(() => {
|
|
@@ -191,7 +191,7 @@ describe("signal", () => {
|
|
|
191
191
|
expect(values).toEqual([1, 2])
|
|
192
192
|
})
|
|
193
193
|
|
|
194
|
-
test(
|
|
194
|
+
test('direct updaters are batch-aware', () => {
|
|
195
195
|
const s = signal(0)
|
|
196
196
|
let calls = 0
|
|
197
197
|
s.direct(() => {
|
|
@@ -207,7 +207,7 @@ describe("signal", () => {
|
|
|
207
207
|
expect(calls).toBe(1)
|
|
208
208
|
})
|
|
209
209
|
|
|
210
|
-
test(
|
|
210
|
+
test('direct updater with no prior direct array initializes lazily', () => {
|
|
211
211
|
const s = signal(0)
|
|
212
212
|
const internal = s as unknown as { _d: ((() => void) | null)[] | null }
|
|
213
213
|
expect(internal._d).toBeNull()
|
|
@@ -217,14 +217,14 @@ describe("signal", () => {
|
|
|
217
217
|
})
|
|
218
218
|
})
|
|
219
219
|
|
|
220
|
-
describe(
|
|
221
|
-
test(
|
|
222
|
-
const warnSpy = vi.spyOn(console,
|
|
220
|
+
describe('signal misuse warning in dev', () => {
|
|
221
|
+
test('warns when signal is called with arguments', () => {
|
|
222
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
223
223
|
const s = signal(42)
|
|
224
224
|
// Call signal with an argument (common mistake — trying to set via call)
|
|
225
225
|
;(s as unknown as (v: number) => number)(99)
|
|
226
226
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
227
|
-
expect.stringContaining(
|
|
227
|
+
expect.stringContaining('signal() was called with an argument'),
|
|
228
228
|
)
|
|
229
229
|
// Value should not change — the argument is ignored
|
|
230
230
|
expect(s()).toBe(42)
|
package/src/tests/store.test.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { effect } from
|
|
2
|
-
import { reconcile } from
|
|
3
|
-
import { createStore, isStore } from
|
|
1
|
+
import { effect } from '../effect'
|
|
2
|
+
import { reconcile } from '../reconcile'
|
|
3
|
+
import { createStore, isStore } from '../store'
|
|
4
4
|
|
|
5
|
-
describe(
|
|
6
|
-
test(
|
|
5
|
+
describe('createStore', () => {
|
|
6
|
+
test('reads primitive properties reactively', () => {
|
|
7
7
|
const state = createStore({ count: 0 })
|
|
8
8
|
const calls: number[] = []
|
|
9
9
|
effect(() => {
|
|
@@ -16,19 +16,19 @@ describe("createStore", () => {
|
|
|
16
16
|
expect(calls).toEqual([0, 1])
|
|
17
17
|
})
|
|
18
18
|
|
|
19
|
-
test(
|
|
20
|
-
const state = createStore({ user: { name:
|
|
19
|
+
test('deep reactive — nested object', () => {
|
|
20
|
+
const state = createStore({ user: { name: 'Alice', age: 30 } })
|
|
21
21
|
const names: string[] = []
|
|
22
22
|
effect(() => {
|
|
23
23
|
names.push(state.user.name)
|
|
24
24
|
})
|
|
25
|
-
expect(names).toEqual([
|
|
26
|
-
state.user.name =
|
|
27
|
-
expect(names).toEqual([
|
|
25
|
+
expect(names).toEqual(['Alice'])
|
|
26
|
+
state.user.name = 'Bob'
|
|
27
|
+
expect(names).toEqual(['Alice', 'Bob'])
|
|
28
28
|
})
|
|
29
29
|
|
|
30
|
-
test(
|
|
31
|
-
const state = createStore({ user: { name:
|
|
30
|
+
test('deep reactive — nested change does NOT re-run parent-only effects', () => {
|
|
31
|
+
const state = createStore({ user: { name: 'Alice', age: 30 } })
|
|
32
32
|
const userCalls: number[] = []
|
|
33
33
|
const nameCalls: string[] = []
|
|
34
34
|
effect(() => {
|
|
@@ -41,10 +41,10 @@ describe("createStore", () => {
|
|
|
41
41
|
expect(userCalls.length).toBe(1)
|
|
42
42
|
state.user.age = 31
|
|
43
43
|
// Only the age signal fires — user object didn't change, name didn't change
|
|
44
|
-
expect(nameCalls).toEqual([
|
|
44
|
+
expect(nameCalls).toEqual(['Alice']) // name effect didn't re-run
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
-
test(
|
|
47
|
+
test('array — tracks length on push', () => {
|
|
48
48
|
const state = createStore({ items: [1, 2, 3] })
|
|
49
49
|
const lengths: number[] = []
|
|
50
50
|
effect(() => {
|
|
@@ -55,20 +55,20 @@ describe("createStore", () => {
|
|
|
55
55
|
expect(lengths).toEqual([3, 4])
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
-
test(
|
|
59
|
-
const state = createStore({ items: [
|
|
58
|
+
test('array — tracks index access', () => {
|
|
59
|
+
const state = createStore({ items: ['a', 'b'] })
|
|
60
60
|
const values: string[] = []
|
|
61
61
|
effect(() => {
|
|
62
62
|
values.push(state.items[0] as string)
|
|
63
63
|
})
|
|
64
|
-
expect(values).toEqual([
|
|
65
|
-
state.items[0] =
|
|
66
|
-
expect(values).toEqual([
|
|
67
|
-
state.items[1] =
|
|
68
|
-
expect(values).toEqual([
|
|
64
|
+
expect(values).toEqual(['a'])
|
|
65
|
+
state.items[0] = 'x'
|
|
66
|
+
expect(values).toEqual(['a', 'x'])
|
|
67
|
+
state.items[1] = 'y' // different index — should not re-run this effect
|
|
68
|
+
expect(values).toEqual(['a', 'x'])
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
-
test(
|
|
71
|
+
test('isStore identifies proxy', () => {
|
|
72
72
|
const raw = { x: 1 }
|
|
73
73
|
const store = createStore(raw)
|
|
74
74
|
expect(isStore(store)).toBe(true)
|
|
@@ -77,14 +77,14 @@ describe("createStore", () => {
|
|
|
77
77
|
expect(isStore(42)).toBe(false)
|
|
78
78
|
})
|
|
79
79
|
|
|
80
|
-
test(
|
|
80
|
+
test('same raw object returns same proxy', () => {
|
|
81
81
|
const raw = { a: 1 }
|
|
82
82
|
const s1 = createStore(raw)
|
|
83
83
|
const s2 = createStore(raw)
|
|
84
84
|
expect(s1).toBe(s2)
|
|
85
85
|
})
|
|
86
86
|
|
|
87
|
-
test(
|
|
87
|
+
test('deep nested object mutations trigger fine-grained updates', () => {
|
|
88
88
|
const state = createStore({
|
|
89
89
|
a: { b: { c: { d: 1 } } },
|
|
90
90
|
})
|
|
@@ -97,20 +97,20 @@ describe("createStore", () => {
|
|
|
97
97
|
expect(dValues).toEqual([1, 2])
|
|
98
98
|
})
|
|
99
99
|
|
|
100
|
-
test(
|
|
101
|
-
const state = createStore({ user: { name:
|
|
100
|
+
test('replacing a nested object triggers dependent effects', () => {
|
|
101
|
+
const state = createStore({ user: { name: 'Alice', address: { city: 'NYC' } } })
|
|
102
102
|
const cities: string[] = []
|
|
103
103
|
effect(() => {
|
|
104
104
|
cities.push(state.user.address.city)
|
|
105
105
|
})
|
|
106
|
-
expect(cities).toEqual([
|
|
106
|
+
expect(cities).toEqual(['NYC'])
|
|
107
107
|
// Replace the address object entirely
|
|
108
|
-
state.user.address = { city:
|
|
109
|
-
expect(cities).toEqual([
|
|
108
|
+
state.user.address = { city: 'LA' }
|
|
109
|
+
expect(cities).toEqual(['NYC', 'LA'])
|
|
110
110
|
})
|
|
111
111
|
|
|
112
|
-
test(
|
|
113
|
-
const state = createStore({ items: [
|
|
112
|
+
test('array splice triggers length and index updates', () => {
|
|
113
|
+
const state = createStore({ items: ['a', 'b', 'c', 'd'] })
|
|
114
114
|
const lengths: number[] = []
|
|
115
115
|
effect(() => {
|
|
116
116
|
lengths.push(state.items.length)
|
|
@@ -118,10 +118,10 @@ describe("createStore", () => {
|
|
|
118
118
|
expect(lengths).toEqual([4])
|
|
119
119
|
state.items.splice(1, 2) // removes "b", "c"
|
|
120
120
|
expect(lengths).toEqual([4, 2])
|
|
121
|
-
expect([...state.items]).toEqual([
|
|
121
|
+
expect([...state.items]).toEqual(['a', 'd'])
|
|
122
122
|
})
|
|
123
123
|
|
|
124
|
-
test(
|
|
124
|
+
test('array pop triggers length update', () => {
|
|
125
125
|
const state = createStore({ items: [1, 2, 3] })
|
|
126
126
|
const lengths: number[] = []
|
|
127
127
|
effect(() => {
|
|
@@ -132,7 +132,7 @@ describe("createStore", () => {
|
|
|
132
132
|
expect(lengths).toEqual([3, 2])
|
|
133
133
|
})
|
|
134
134
|
|
|
135
|
-
test(
|
|
135
|
+
test('delete property triggers reactivity', () => {
|
|
136
136
|
const state = createStore({ a: 1, b: 2 } as Record<string, number | undefined>)
|
|
137
137
|
const bValues: (number | undefined)[] = []
|
|
138
138
|
effect(() => {
|
|
@@ -143,7 +143,7 @@ describe("createStore", () => {
|
|
|
143
143
|
expect(bValues).toEqual([2, undefined])
|
|
144
144
|
})
|
|
145
145
|
|
|
146
|
-
test(
|
|
146
|
+
test('setting array length directly triggers reactivity', () => {
|
|
147
147
|
const state = createStore({ items: [1, 2, 3, 4, 5] })
|
|
148
148
|
const lengths: number[] = []
|
|
149
149
|
effect(() => {
|
|
@@ -154,36 +154,36 @@ describe("createStore", () => {
|
|
|
154
154
|
expect(lengths).toEqual([5, 2])
|
|
155
155
|
})
|
|
156
156
|
|
|
157
|
-
test(
|
|
158
|
-
const sym = Symbol(
|
|
157
|
+
test('symbol property access does not trigger tracking', () => {
|
|
158
|
+
const sym = Symbol('test')
|
|
159
159
|
const raw = { x: 1 } as Record<string | symbol, unknown>
|
|
160
|
-
raw[sym] =
|
|
160
|
+
raw[sym] = 'hidden'
|
|
161
161
|
const state = createStore(raw)
|
|
162
|
-
expect(state[sym]).toBe(
|
|
162
|
+
expect(state[sym]).toBe('hidden')
|
|
163
163
|
})
|
|
164
164
|
|
|
165
|
-
test(
|
|
166
|
-
const sym = Symbol(
|
|
165
|
+
test('symbol property set goes through to target', () => {
|
|
166
|
+
const sym = Symbol('test')
|
|
167
167
|
const state = createStore({} as Record<string | symbol, unknown>)
|
|
168
|
-
state[sym] =
|
|
169
|
-
expect(state[sym]).toBe(
|
|
168
|
+
state[sym] = 'value'
|
|
169
|
+
expect(state[sym]).toBe('value')
|
|
170
170
|
})
|
|
171
171
|
|
|
172
|
-
test(
|
|
172
|
+
test('has trap works with in operator', () => {
|
|
173
173
|
const state = createStore({ a: 1 })
|
|
174
|
-
expect(
|
|
175
|
-
expect(
|
|
174
|
+
expect('a' in state).toBe(true)
|
|
175
|
+
expect('b' in state).toBe(false)
|
|
176
176
|
})
|
|
177
177
|
|
|
178
|
-
test(
|
|
178
|
+
test('ownKeys returns correct keys', () => {
|
|
179
179
|
const state = createStore({ a: 1, b: 2 })
|
|
180
|
-
expect(Object.keys(state)).toEqual([
|
|
180
|
+
expect(Object.keys(state)).toEqual(['a', 'b'])
|
|
181
181
|
})
|
|
182
182
|
})
|
|
183
183
|
|
|
184
|
-
describe(
|
|
185
|
-
test(
|
|
186
|
-
const state = createStore({ name:
|
|
184
|
+
describe('reconcile', () => {
|
|
185
|
+
test('updates only changed scalar properties', () => {
|
|
186
|
+
const state = createStore({ name: 'Alice', age: 30 })
|
|
187
187
|
const nameCalls: string[] = []
|
|
188
188
|
const ageCalls: number[] = []
|
|
189
189
|
effect(() => {
|
|
@@ -192,41 +192,41 @@ describe("reconcile", () => {
|
|
|
192
192
|
effect(() => {
|
|
193
193
|
ageCalls.push(state.age)
|
|
194
194
|
})
|
|
195
|
-
reconcile({ name:
|
|
196
|
-
expect(nameCalls).toEqual([
|
|
195
|
+
reconcile({ name: 'Alice', age: 31 }, state)
|
|
196
|
+
expect(nameCalls).toEqual(['Alice']) // unchanged — no re-run
|
|
197
197
|
expect(ageCalls).toEqual([30, 31]) // changed — re-ran
|
|
198
198
|
})
|
|
199
199
|
|
|
200
|
-
test(
|
|
201
|
-
const state = createStore({ user: { name:
|
|
200
|
+
test('reconciles nested objects recursively', () => {
|
|
201
|
+
const state = createStore({ user: { name: 'Alice', age: 30 } })
|
|
202
202
|
const nameCalls: string[] = []
|
|
203
203
|
effect(() => {
|
|
204
204
|
nameCalls.push(state.user.name)
|
|
205
205
|
})
|
|
206
|
-
reconcile({ user: { name:
|
|
207
|
-
expect(nameCalls).toEqual([
|
|
206
|
+
reconcile({ user: { name: 'Bob', age: 30 } }, state)
|
|
207
|
+
expect(nameCalls).toEqual(['Alice', 'Bob'])
|
|
208
208
|
})
|
|
209
209
|
|
|
210
|
-
test(
|
|
211
|
-
const state = createStore({ items: [
|
|
210
|
+
test('reconciles arrays by index', () => {
|
|
211
|
+
const state = createStore({ items: ['a', 'b', 'c'] })
|
|
212
212
|
const calls: string[][] = []
|
|
213
213
|
effect(() => {
|
|
214
214
|
calls.push([...state.items])
|
|
215
215
|
})
|
|
216
|
-
reconcile({ items: [
|
|
217
|
-
expect(state.items[1]).toBe(
|
|
216
|
+
reconcile({ items: ['a', 'X', 'c'] }, state)
|
|
217
|
+
expect(state.items[1]).toBe('X')
|
|
218
218
|
expect(calls.length).toBe(2) // initial + after reconcile
|
|
219
219
|
})
|
|
220
220
|
|
|
221
|
-
test(
|
|
221
|
+
test('trims excess array elements', () => {
|
|
222
222
|
const state = createStore({ items: [1, 2, 3, 4, 5] })
|
|
223
223
|
reconcile({ items: [1, 2] }, state)
|
|
224
224
|
expect(state.items.length).toBe(2)
|
|
225
225
|
})
|
|
226
226
|
|
|
227
|
-
test(
|
|
227
|
+
test('removes deleted keys', () => {
|
|
228
228
|
const state = createStore({ a: 1, b: 2, c: 3 } as Record<string, number>)
|
|
229
229
|
reconcile({ a: 1, b: 2 }, state)
|
|
230
|
-
expect(
|
|
230
|
+
expect('c' in state).toBe(false)
|
|
231
231
|
})
|
|
232
232
|
})
|