@pyreon/reactivity 0.24.5 → 0.24.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/package.json +1 -4
- package/src/batch.ts +0 -196
- package/src/cell.ts +0 -72
- package/src/computed.ts +0 -313
- package/src/createSelector.ts +0 -109
- package/src/debug.ts +0 -134
- package/src/effect.ts +0 -467
- package/src/env.d.ts +0 -6
- package/src/index.ts +0 -60
- package/src/lpih.ts +0 -227
- package/src/manifest.ts +0 -660
- package/src/reactive-devtools.ts +0 -494
- package/src/reactive-trace.ts +0 -142
- package/src/reconcile.ts +0 -118
- package/src/resource.ts +0 -84
- package/src/scope.ts +0 -123
- package/src/signal.ts +0 -261
- package/src/store.ts +0 -250
- package/src/tests/batch.test.ts +0 -751
- package/src/tests/bind.test.ts +0 -84
- package/src/tests/branches.test.ts +0 -343
- package/src/tests/cell.test.ts +0 -159
- package/src/tests/computed.test.ts +0 -436
- package/src/tests/coverage-hardening.test.ts +0 -471
- package/src/tests/createSelector.test.ts +0 -291
- package/src/tests/debug.test.ts +0 -196
- package/src/tests/effect.test.ts +0 -464
- package/src/tests/fanout-repro.test.ts +0 -179
- package/src/tests/lpih-source-location.test.ts +0 -277
- package/src/tests/lpih.test.ts +0 -351
- package/src/tests/manifest-snapshot.test.ts +0 -96
- package/src/tests/reactive-devtools-treeshake.test.ts +0 -48
- package/src/tests/reactive-devtools.test.ts +0 -296
- package/src/tests/reactive-trace.test.ts +0 -102
- package/src/tests/reconcile-security.test.ts +0 -45
- package/src/tests/resource.test.ts +0 -326
- package/src/tests/scope.test.ts +0 -231
- package/src/tests/signal.test.ts +0 -368
- package/src/tests/store.test.ts +0 -286
- package/src/tests/tracking.test.ts +0 -158
- package/src/tests/vue-parity.test.ts +0 -191
- package/src/tests/watch.test.ts +0 -246
- package/src/tracking.ts +0 -139
- package/src/watch.ts +0 -68
package/src/tests/signal.test.ts
DELETED
|
@@ -1,368 +0,0 @@
|
|
|
1
|
-
import { batch } from '../batch'
|
|
2
|
-
import { onSignalUpdate } from '../debug'
|
|
3
|
-
import { effect } from '../effect'
|
|
4
|
-
import { signal } from '../signal'
|
|
5
|
-
|
|
6
|
-
describe('signal', () => {
|
|
7
|
-
test('reads initial value', () => {
|
|
8
|
-
const s = signal(42)
|
|
9
|
-
expect(s()).toBe(42)
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
test('set updates value', () => {
|
|
13
|
-
const s = signal(0)
|
|
14
|
-
s.set(10)
|
|
15
|
-
expect(s()).toBe(10)
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
test('update transforms value', () => {
|
|
19
|
-
const s = signal(5)
|
|
20
|
-
s.update((n) => n * 2)
|
|
21
|
-
expect(s()).toBe(10)
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
test('set with same value does not notify', () => {
|
|
25
|
-
const s = signal(1)
|
|
26
|
-
let calls = 0
|
|
27
|
-
effect(() => {
|
|
28
|
-
s() // track
|
|
29
|
-
calls++
|
|
30
|
-
})
|
|
31
|
-
expect(calls).toBe(1) // initial run
|
|
32
|
-
s.set(1) // same value — no notification
|
|
33
|
-
expect(calls).toBe(1)
|
|
34
|
-
s.set(2) // different value — notifies
|
|
35
|
-
expect(calls).toBe(2)
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
test('works with objects', () => {
|
|
39
|
-
const s = signal({ x: 1 })
|
|
40
|
-
s.update((o) => ({ ...o, x: 2 }))
|
|
41
|
-
expect(s().x).toBe(2)
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
test('works with null and undefined', () => {
|
|
45
|
-
const s = signal<string | null>(null)
|
|
46
|
-
expect(s()).toBeNull()
|
|
47
|
-
s.set('hello')
|
|
48
|
-
expect(s()).toBe('hello')
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
test('peek reads value without tracking', () => {
|
|
52
|
-
const s = signal(42)
|
|
53
|
-
let count = 0
|
|
54
|
-
effect(() => {
|
|
55
|
-
s.peek() // should NOT track
|
|
56
|
-
count++
|
|
57
|
-
})
|
|
58
|
-
expect(count).toBe(1)
|
|
59
|
-
s.set(100)
|
|
60
|
-
expect(count).toBe(1) // no re-run because peek doesn't track
|
|
61
|
-
expect(s.peek()).toBe(100)
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
test('subscribe adds a static listener', () => {
|
|
65
|
-
const s = signal(0)
|
|
66
|
-
let notified = 0
|
|
67
|
-
const unsub = s.subscribe(() => {
|
|
68
|
-
notified++
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
s.set(1)
|
|
72
|
-
expect(notified).toBe(1)
|
|
73
|
-
s.set(2)
|
|
74
|
-
expect(notified).toBe(2)
|
|
75
|
-
|
|
76
|
-
unsub()
|
|
77
|
-
s.set(3)
|
|
78
|
-
expect(notified).toBe(2) // unsubscribed
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
test('subscribe disposer is safe to call multiple times', () => {
|
|
82
|
-
const s = signal(0)
|
|
83
|
-
const unsub = s.subscribe(() => {})
|
|
84
|
-
unsub()
|
|
85
|
-
unsub() // should not throw
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
test('label getter returns name from options', () => {
|
|
89
|
-
const s = signal(0, { name: 'counter' })
|
|
90
|
-
expect(s.label).toBe('counter')
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
test('label setter updates the name', () => {
|
|
94
|
-
const s = signal(0)
|
|
95
|
-
expect(s.label).toBeUndefined()
|
|
96
|
-
s.label = 'renamed'
|
|
97
|
-
expect(s.label).toBe('renamed')
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
test('debug() returns signal info', () => {
|
|
101
|
-
const s = signal(42, { name: 'test' })
|
|
102
|
-
const info = s.debug()
|
|
103
|
-
expect(info.name).toBe('test')
|
|
104
|
-
expect(info.value).toBe(42)
|
|
105
|
-
expect(info.subscriberCount).toBe(0)
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
test('debug() reports subscriber count', () => {
|
|
109
|
-
const s = signal(0)
|
|
110
|
-
s.subscribe(() => {})
|
|
111
|
-
s.subscribe(() => {})
|
|
112
|
-
const info = s.debug()
|
|
113
|
-
expect(info.subscriberCount).toBe(2)
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
test('signal without options has undefined name', () => {
|
|
117
|
-
const s = signal(0)
|
|
118
|
-
expect(s.label).toBeUndefined()
|
|
119
|
-
const info = s.debug()
|
|
120
|
-
expect(info.name).toBeUndefined()
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
describe('direct updater disposal', () => {
|
|
124
|
-
test('disposed direct updater is not called on subsequent updates', () => {
|
|
125
|
-
const s = signal(0)
|
|
126
|
-
let called = 0
|
|
127
|
-
const dispose = s.direct(() => {
|
|
128
|
-
called++
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
s.set(1)
|
|
132
|
-
expect(called).toBe(1)
|
|
133
|
-
|
|
134
|
-
dispose()
|
|
135
|
-
s.set(2)
|
|
136
|
-
expect(called).toBe(1) // not called after disposal
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
test('multiple direct updaters, dispose one, others still fire', () => {
|
|
140
|
-
const s = signal(0)
|
|
141
|
-
let calls1 = 0
|
|
142
|
-
let calls2 = 0
|
|
143
|
-
let calls3 = 0
|
|
144
|
-
|
|
145
|
-
const dispose1 = s.direct(() => {
|
|
146
|
-
calls1++
|
|
147
|
-
})
|
|
148
|
-
s.direct(() => {
|
|
149
|
-
calls2++
|
|
150
|
-
})
|
|
151
|
-
s.direct(() => {
|
|
152
|
-
calls3++
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
s.set(1)
|
|
156
|
-
expect(calls1).toBe(1)
|
|
157
|
-
expect(calls2).toBe(1)
|
|
158
|
-
expect(calls3).toBe(1)
|
|
159
|
-
|
|
160
|
-
dispose1()
|
|
161
|
-
s.set(2)
|
|
162
|
-
expect(calls1).toBe(1) // disposed — not called
|
|
163
|
-
expect(calls2).toBe(2) // still active
|
|
164
|
-
expect(calls3).toBe(2) // still active
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
test('direct updater is removed from the set after disposal', () => {
|
|
168
|
-
const s = signal(0)
|
|
169
|
-
let calls = 0
|
|
170
|
-
const dispose = s.direct(() => {
|
|
171
|
-
calls++
|
|
172
|
-
})
|
|
173
|
-
|
|
174
|
-
// Internal `_d` is a Set (not an unbounded array — see signal.ts).
|
|
175
|
-
const internal = s as unknown as { _d: Set<() => void> | null }
|
|
176
|
-
expect(internal._d).not.toBeNull()
|
|
177
|
-
expect(internal._d!.size).toBe(1)
|
|
178
|
-
s.set(1)
|
|
179
|
-
expect(calls).toBe(1)
|
|
180
|
-
|
|
181
|
-
dispose()
|
|
182
|
-
// O(1) removal — the slot is GONE, not nulled-and-retained.
|
|
183
|
-
expect(internal._d!.size).toBe(0)
|
|
184
|
-
s.set(2)
|
|
185
|
-
expect(calls).toBe(1) // disposed updater not invoked
|
|
186
|
-
})
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
describe('signal.direct() for template binding', () => {
|
|
190
|
-
test('direct updater is called synchronously on signal change', () => {
|
|
191
|
-
const s = signal(0)
|
|
192
|
-
const values: number[] = []
|
|
193
|
-
s.direct(() => {
|
|
194
|
-
values.push(s.peek())
|
|
195
|
-
})
|
|
196
|
-
|
|
197
|
-
s.set(1)
|
|
198
|
-
expect(values).toEqual([1])
|
|
199
|
-
s.set(2)
|
|
200
|
-
expect(values).toEqual([1, 2])
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
test('direct updaters are batch-aware', () => {
|
|
204
|
-
const s = signal(0)
|
|
205
|
-
let calls = 0
|
|
206
|
-
s.direct(() => {
|
|
207
|
-
calls++
|
|
208
|
-
})
|
|
209
|
-
|
|
210
|
-
batch(() => {
|
|
211
|
-
s.set(1)
|
|
212
|
-
s.set(2)
|
|
213
|
-
s.set(3)
|
|
214
|
-
})
|
|
215
|
-
// Should only be called once after batch (deduplication via Set)
|
|
216
|
-
expect(calls).toBe(1)
|
|
217
|
-
})
|
|
218
|
-
|
|
219
|
-
test('direct updater set initializes lazily', () => {
|
|
220
|
-
const s = signal(0)
|
|
221
|
-
const internal = s as unknown as { _d: Set<() => void> | null }
|
|
222
|
-
expect(internal._d).toBeNull()
|
|
223
|
-
s.direct(() => {})
|
|
224
|
-
expect(internal._d).not.toBeNull()
|
|
225
|
-
expect(internal._d!.size).toBe(1)
|
|
226
|
-
})
|
|
227
|
-
|
|
228
|
-
test('churned direct bindings do not accumulate (no unbounded growth)', () => {
|
|
229
|
-
// Regression for the array-form leak: a long-lived signal whose
|
|
230
|
-
// direct bindings register+dispose repeatedly (e.g. <For> rows
|
|
231
|
-
// re-mounting) must keep `_d` bounded to the LIVE set, not grow
|
|
232
|
-
// one permanent dead slot per ever-registered binding.
|
|
233
|
-
const s = signal(0)
|
|
234
|
-
const internal = s as unknown as { _d: Set<() => void> | null }
|
|
235
|
-
for (let i = 0; i < 10_000; i++) {
|
|
236
|
-
const dispose = s.direct(() => {})
|
|
237
|
-
dispose()
|
|
238
|
-
}
|
|
239
|
-
expect(internal._d!.size).toBe(0)
|
|
240
|
-
// One live binding survives → notify cost is O(live), not O(10_000).
|
|
241
|
-
const dispose = s.direct(() => {})
|
|
242
|
-
expect(internal._d!.size).toBe(1)
|
|
243
|
-
dispose()
|
|
244
|
-
})
|
|
245
|
-
})
|
|
246
|
-
|
|
247
|
-
describe('signal misuse warning in dev', () => {
|
|
248
|
-
test('warns when signal is called with arguments', () => {
|
|
249
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
250
|
-
const s = signal(42)
|
|
251
|
-
// Call signal with an argument (common mistake — trying to set via call)
|
|
252
|
-
;(s as unknown as (v: number) => number)(99)
|
|
253
|
-
expect(warnSpy).toHaveBeenCalledWith(
|
|
254
|
-
expect.stringContaining('signal() was called with an argument'),
|
|
255
|
-
)
|
|
256
|
-
// Value should not change — the argument is ignored
|
|
257
|
-
expect(s()).toBe(42)
|
|
258
|
-
warnSpy.mockRestore()
|
|
259
|
-
})
|
|
260
|
-
})
|
|
261
|
-
|
|
262
|
-
// Regression: pre-fix, a throwing trace listener (registered via
|
|
263
|
-
// onSignalUpdate) was called inline between the `_v` write and subscriber
|
|
264
|
-
// notification. If it threw, `_v` was updated but no effects ran — divergent
|
|
265
|
-
// state. Fix wraps the trace dispatch in try/catch.
|
|
266
|
-
describe('throwing trace listener does not corrupt state', () => {
|
|
267
|
-
test('subscribers still fire when a trace listener throws', () => {
|
|
268
|
-
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
269
|
-
const s = signal(0)
|
|
270
|
-
const subscriberRuns: number[] = []
|
|
271
|
-
effect(() => {
|
|
272
|
-
subscriberRuns.push(s())
|
|
273
|
-
})
|
|
274
|
-
|
|
275
|
-
const dispose = onSignalUpdate(() => {
|
|
276
|
-
throw new Error('trace listener boom')
|
|
277
|
-
})
|
|
278
|
-
|
|
279
|
-
// Pre-fix: the throwing listener would prevent the subscriber from
|
|
280
|
-
// firing. Post-fix: the listener throws, gets logged, subscriber runs.
|
|
281
|
-
s.set(5)
|
|
282
|
-
expect(subscriberRuns).toEqual([0, 5])
|
|
283
|
-
// Dev mode logs the listener error — bisect-verify the wrap is in place
|
|
284
|
-
expect(errorSpy).toHaveBeenCalledWith(
|
|
285
|
-
expect.stringContaining('trace listener threw'),
|
|
286
|
-
expect.any(Error),
|
|
287
|
-
)
|
|
288
|
-
|
|
289
|
-
dispose()
|
|
290
|
-
errorSpy.mockRestore()
|
|
291
|
-
})
|
|
292
|
-
|
|
293
|
-
test('multiple writes survive a chronically-broken listener', () => {
|
|
294
|
-
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
295
|
-
const s = signal(0)
|
|
296
|
-
const seen: number[] = []
|
|
297
|
-
effect(() => {
|
|
298
|
-
seen.push(s())
|
|
299
|
-
})
|
|
300
|
-
|
|
301
|
-
const dispose = onSignalUpdate(() => {
|
|
302
|
-
throw new Error('always')
|
|
303
|
-
})
|
|
304
|
-
s.set(1)
|
|
305
|
-
s.set(2)
|
|
306
|
-
s.set(3)
|
|
307
|
-
expect(seen).toEqual([0, 1, 2, 3])
|
|
308
|
-
|
|
309
|
-
dispose()
|
|
310
|
-
errorSpy.mockRestore()
|
|
311
|
-
})
|
|
312
|
-
})
|
|
313
|
-
|
|
314
|
-
// L7 audit gap: cap-iteration in notifySubscribers caps at originalSize to
|
|
315
|
-
// avoid infinite loops if a subscriber re-inserts itself or others into the
|
|
316
|
-
// Set. The contract: subscribers added DURING notification fire next round,
|
|
317
|
-
// not this round (no double-fire). Pin the contract.
|
|
318
|
-
describe('subscriber cap-iteration during notification (L7)', () => {
|
|
319
|
-
test('subscriber added mid-notification fires on the NEXT write, not the current one', () => {
|
|
320
|
-
const s = signal(0)
|
|
321
|
-
const fires: string[] = []
|
|
322
|
-
|
|
323
|
-
const lateSubscriber = (): void => {
|
|
324
|
-
fires.push('late')
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
s.subscribe(() => {
|
|
328
|
-
fires.push('first')
|
|
329
|
-
// Add a NEW subscriber during the notification. Per cap-iteration
|
|
330
|
-
// contract, it should NOT fire this round.
|
|
331
|
-
s.subscribe(lateSubscriber)
|
|
332
|
-
})
|
|
333
|
-
|
|
334
|
-
s.set(1)
|
|
335
|
-
// Only "first" fires this round — "late" gets registered, doesn't run.
|
|
336
|
-
expect(fires).toEqual(['first'])
|
|
337
|
-
|
|
338
|
-
// Next write — both fire (first was already there, late is now registered).
|
|
339
|
-
fires.length = 0
|
|
340
|
-
s.set(2)
|
|
341
|
-
expect(fires.sort()).toEqual(['first', 'late'])
|
|
342
|
-
})
|
|
343
|
-
|
|
344
|
-
test('subscriber that disposes itself mid-iteration cleans up cleanly', () => {
|
|
345
|
-
const s = signal(0)
|
|
346
|
-
let disposeMe: (() => void) | null = null
|
|
347
|
-
let firstRuns = 0
|
|
348
|
-
let secondRuns = 0
|
|
349
|
-
|
|
350
|
-
s.subscribe(() => {
|
|
351
|
-
firstRuns++
|
|
352
|
-
})
|
|
353
|
-
disposeMe = s.subscribe(() => {
|
|
354
|
-
secondRuns++
|
|
355
|
-
disposeMe?.() // self-dispose
|
|
356
|
-
})
|
|
357
|
-
|
|
358
|
-
s.set(1)
|
|
359
|
-
expect(firstRuns).toBe(1)
|
|
360
|
-
expect(secondRuns).toBe(1)
|
|
361
|
-
|
|
362
|
-
// Next write — second is gone, first still fires.
|
|
363
|
-
s.set(2)
|
|
364
|
-
expect(firstRuns).toBe(2)
|
|
365
|
-
expect(secondRuns).toBe(1)
|
|
366
|
-
})
|
|
367
|
-
})
|
|
368
|
-
})
|
package/src/tests/store.test.ts
DELETED
|
@@ -1,286 +0,0 @@
|
|
|
1
|
-
import { effect } from '../effect'
|
|
2
|
-
import { reconcile } from '../reconcile'
|
|
3
|
-
import { createStore, isStore } from '../store'
|
|
4
|
-
|
|
5
|
-
describe('createStore', () => {
|
|
6
|
-
test('reads primitive properties reactively', () => {
|
|
7
|
-
const state = createStore({ count: 0 })
|
|
8
|
-
const calls: number[] = []
|
|
9
|
-
effect(() => {
|
|
10
|
-
calls.push(state.count)
|
|
11
|
-
})
|
|
12
|
-
expect(calls).toEqual([0])
|
|
13
|
-
state.count = 1
|
|
14
|
-
expect(calls).toEqual([0, 1])
|
|
15
|
-
state.count = 1 // no-op (same value)
|
|
16
|
-
expect(calls).toEqual([0, 1])
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
test('deep reactive — nested object', () => {
|
|
20
|
-
const state = createStore({ user: { name: 'Alice', age: 30 } })
|
|
21
|
-
const names: string[] = []
|
|
22
|
-
effect(() => {
|
|
23
|
-
names.push(state.user.name)
|
|
24
|
-
})
|
|
25
|
-
expect(names).toEqual(['Alice'])
|
|
26
|
-
state.user.name = 'Bob'
|
|
27
|
-
expect(names).toEqual(['Alice', 'Bob'])
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
test('deep reactive — nested change does NOT re-run parent-only effects', () => {
|
|
31
|
-
const state = createStore({ user: { name: 'Alice', age: 30 } })
|
|
32
|
-
const userCalls: number[] = []
|
|
33
|
-
const nameCalls: string[] = []
|
|
34
|
-
effect(() => {
|
|
35
|
-
userCalls.push(1)
|
|
36
|
-
void state.user
|
|
37
|
-
}) // tracks user object
|
|
38
|
-
effect(() => {
|
|
39
|
-
nameCalls.push(state.user.name)
|
|
40
|
-
}) // tracks name only
|
|
41
|
-
expect(userCalls.length).toBe(1)
|
|
42
|
-
state.user.age = 31
|
|
43
|
-
// Only the age signal fires — user object didn't change, name didn't change
|
|
44
|
-
expect(nameCalls).toEqual(['Alice']) // name effect didn't re-run
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
test('array — tracks length on push', () => {
|
|
48
|
-
const state = createStore({ items: [1, 2, 3] })
|
|
49
|
-
const lengths: number[] = []
|
|
50
|
-
effect(() => {
|
|
51
|
-
lengths.push(state.items.length)
|
|
52
|
-
})
|
|
53
|
-
expect(lengths).toEqual([3])
|
|
54
|
-
state.items.push(4)
|
|
55
|
-
expect(lengths).toEqual([3, 4])
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
test('array — tracks index access', () => {
|
|
59
|
-
const state = createStore({ items: ['a', 'b'] })
|
|
60
|
-
const values: string[] = []
|
|
61
|
-
effect(() => {
|
|
62
|
-
values.push(state.items[0] as string)
|
|
63
|
-
})
|
|
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
|
-
})
|
|
70
|
-
|
|
71
|
-
test('isStore identifies proxy', () => {
|
|
72
|
-
const raw = { x: 1 }
|
|
73
|
-
const store = createStore(raw)
|
|
74
|
-
expect(isStore(store)).toBe(true)
|
|
75
|
-
expect(isStore(raw)).toBe(false)
|
|
76
|
-
expect(isStore(null)).toBe(false)
|
|
77
|
-
expect(isStore(42)).toBe(false)
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
test('same raw object returns same proxy', () => {
|
|
81
|
-
const raw = { a: 1 }
|
|
82
|
-
const s1 = createStore(raw)
|
|
83
|
-
const s2 = createStore(raw)
|
|
84
|
-
expect(s1).toBe(s2)
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
test('deep nested object mutations trigger fine-grained updates', () => {
|
|
88
|
-
const state = createStore({
|
|
89
|
-
a: { b: { c: { d: 1 } } },
|
|
90
|
-
})
|
|
91
|
-
const dValues: number[] = []
|
|
92
|
-
effect(() => {
|
|
93
|
-
dValues.push(state.a.b.c.d)
|
|
94
|
-
})
|
|
95
|
-
expect(dValues).toEqual([1])
|
|
96
|
-
state.a.b.c.d = 2
|
|
97
|
-
expect(dValues).toEqual([1, 2])
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
test('replacing a nested object triggers dependent effects', () => {
|
|
101
|
-
const state = createStore({ user: { name: 'Alice', address: { city: 'NYC' } } })
|
|
102
|
-
const cities: string[] = []
|
|
103
|
-
effect(() => {
|
|
104
|
-
cities.push(state.user.address.city)
|
|
105
|
-
})
|
|
106
|
-
expect(cities).toEqual(['NYC'])
|
|
107
|
-
// Replace the address object entirely
|
|
108
|
-
state.user.address = { city: 'LA' }
|
|
109
|
-
expect(cities).toEqual(['NYC', 'LA'])
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
test('array splice triggers length and index updates', () => {
|
|
113
|
-
const state = createStore({ items: ['a', 'b', 'c', 'd'] })
|
|
114
|
-
const lengths: number[] = []
|
|
115
|
-
effect(() => {
|
|
116
|
-
lengths.push(state.items.length)
|
|
117
|
-
})
|
|
118
|
-
expect(lengths).toEqual([4])
|
|
119
|
-
state.items.splice(1, 2) // removes "b", "c"
|
|
120
|
-
expect(lengths).toEqual([4, 2])
|
|
121
|
-
expect([...state.items]).toEqual(['a', 'd'])
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
test('array pop triggers length update', () => {
|
|
125
|
-
const state = createStore({ items: [1, 2, 3] })
|
|
126
|
-
const lengths: number[] = []
|
|
127
|
-
effect(() => {
|
|
128
|
-
lengths.push(state.items.length)
|
|
129
|
-
})
|
|
130
|
-
expect(lengths).toEqual([3])
|
|
131
|
-
state.items.pop()
|
|
132
|
-
expect(lengths).toEqual([3, 2])
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
test('delete property triggers reactivity', () => {
|
|
136
|
-
const state = createStore({ a: 1, b: 2 } as Record<string, number | undefined>)
|
|
137
|
-
const bValues: (number | undefined)[] = []
|
|
138
|
-
effect(() => {
|
|
139
|
-
bValues.push(state.b)
|
|
140
|
-
})
|
|
141
|
-
expect(bValues).toEqual([2])
|
|
142
|
-
delete state.b
|
|
143
|
-
expect(bValues).toEqual([2, undefined])
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
// Regression: delete-then-reassign cycle. Pre-fix `deleteProperty` removed
|
|
147
|
-
// the entry from `propSignals`, so a later `set` on the same key created a
|
|
148
|
-
// FRESH signal — but every effect that previously read the key was tracked
|
|
149
|
-
// against the OLD signal, which had been dropped. Effects never re-ran on
|
|
150
|
-
// the reassign. Fix keeps the signal in `propSignals`, and `get` short-
|
|
151
|
-
// circuits to the existing signal when the key isn't `hasOwn` but a tracked
|
|
152
|
-
// signal exists. See store.ts (deleteProperty, get).
|
|
153
|
-
test('delete-then-reassign re-runs effects (signal identity preserved)', () => {
|
|
154
|
-
const state = createStore({ a: 1, b: 2 } as Record<string, number | undefined>)
|
|
155
|
-
const bValues: (number | undefined)[] = []
|
|
156
|
-
effect(() => {
|
|
157
|
-
bValues.push(state.b)
|
|
158
|
-
})
|
|
159
|
-
expect(bValues).toEqual([2])
|
|
160
|
-
delete state.b
|
|
161
|
-
expect(bValues).toEqual([2, undefined])
|
|
162
|
-
state.b = 99
|
|
163
|
-
expect(bValues).toEqual([2, undefined, 99])
|
|
164
|
-
// Cycle a second time to lock the contract — same signal identity reused.
|
|
165
|
-
delete state.b
|
|
166
|
-
state.b = 7
|
|
167
|
-
expect(bValues).toEqual([2, undefined, 99, undefined, 7])
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
// Regression: built-in objects with internal slots (Map, Set, Date, …) used
|
|
171
|
-
// to be wrapped in the proxy, which broke their methods (`Map.prototype.set`
|
|
172
|
-
// called on a Proxy → `TypeError: incompatible receiver`). Fix returns the
|
|
173
|
-
// raw instance from `wrap()` for these types — no fine-grained reactivity
|
|
174
|
-
// for their contents, but they're at least usable. See store.ts
|
|
175
|
-
// (isBuiltinNonProxiable).
|
|
176
|
-
test('Map fields are returned raw, not wrapped (proxy-incompatible)', () => {
|
|
177
|
-
const state = createStore({ users: new Map<string, number>() })
|
|
178
|
-
expect(() => state.users.set('alice', 1)).not.toThrow()
|
|
179
|
-
expect(state.users.get('alice')).toBe(1)
|
|
180
|
-
expect(state.users.size).toBe(1)
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
test('Set fields are returned raw, not wrapped', () => {
|
|
184
|
-
const state = createStore({ tags: new Set<string>() })
|
|
185
|
-
expect(() => state.tags.add('foo')).not.toThrow()
|
|
186
|
-
expect(state.tags.has('foo')).toBe(true)
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
test('Date fields are returned raw, not wrapped', () => {
|
|
190
|
-
const d = new Date('2026-01-01')
|
|
191
|
-
const state = createStore({ created: d })
|
|
192
|
-
expect(state.created.getFullYear()).toBe(2026)
|
|
193
|
-
})
|
|
194
|
-
|
|
195
|
-
test('RegExp fields are returned raw, not wrapped', () => {
|
|
196
|
-
const state = createStore({ rx: /abc/ })
|
|
197
|
-
expect(state.rx.test('abc')).toBe(true)
|
|
198
|
-
})
|
|
199
|
-
|
|
200
|
-
test('setting array length directly triggers reactivity', () => {
|
|
201
|
-
const state = createStore({ items: [1, 2, 3, 4, 5] })
|
|
202
|
-
const lengths: number[] = []
|
|
203
|
-
effect(() => {
|
|
204
|
-
lengths.push(state.items.length)
|
|
205
|
-
})
|
|
206
|
-
expect(lengths).toEqual([5])
|
|
207
|
-
state.items.length = 2
|
|
208
|
-
expect(lengths).toEqual([5, 2])
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
test('symbol property access does not trigger tracking', () => {
|
|
212
|
-
const sym = Symbol('test')
|
|
213
|
-
const raw = { x: 1 } as Record<string | symbol, unknown>
|
|
214
|
-
raw[sym] = 'hidden'
|
|
215
|
-
const state = createStore(raw)
|
|
216
|
-
expect(state[sym]).toBe('hidden')
|
|
217
|
-
})
|
|
218
|
-
|
|
219
|
-
test('symbol property set goes through to target', () => {
|
|
220
|
-
const sym = Symbol('test')
|
|
221
|
-
const state = createStore({} as Record<string | symbol, unknown>)
|
|
222
|
-
state[sym] = 'value'
|
|
223
|
-
expect(state[sym]).toBe('value')
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
test('has trap works with in operator', () => {
|
|
227
|
-
const state = createStore({ a: 1 })
|
|
228
|
-
expect('a' in state).toBe(true)
|
|
229
|
-
expect('b' in state).toBe(false)
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
test('ownKeys returns correct keys', () => {
|
|
233
|
-
const state = createStore({ a: 1, b: 2 })
|
|
234
|
-
expect(Object.keys(state)).toEqual(['a', 'b'])
|
|
235
|
-
})
|
|
236
|
-
})
|
|
237
|
-
|
|
238
|
-
describe('reconcile', () => {
|
|
239
|
-
test('updates only changed scalar properties', () => {
|
|
240
|
-
const state = createStore({ name: 'Alice', age: 30 })
|
|
241
|
-
const nameCalls: string[] = []
|
|
242
|
-
const ageCalls: number[] = []
|
|
243
|
-
effect(() => {
|
|
244
|
-
nameCalls.push(state.name)
|
|
245
|
-
})
|
|
246
|
-
effect(() => {
|
|
247
|
-
ageCalls.push(state.age)
|
|
248
|
-
})
|
|
249
|
-
reconcile({ name: 'Alice', age: 31 }, state)
|
|
250
|
-
expect(nameCalls).toEqual(['Alice']) // unchanged — no re-run
|
|
251
|
-
expect(ageCalls).toEqual([30, 31]) // changed — re-ran
|
|
252
|
-
})
|
|
253
|
-
|
|
254
|
-
test('reconciles nested objects recursively', () => {
|
|
255
|
-
const state = createStore({ user: { name: 'Alice', age: 30 } })
|
|
256
|
-
const nameCalls: string[] = []
|
|
257
|
-
effect(() => {
|
|
258
|
-
nameCalls.push(state.user.name)
|
|
259
|
-
})
|
|
260
|
-
reconcile({ user: { name: 'Bob', age: 30 } }, state)
|
|
261
|
-
expect(nameCalls).toEqual(['Alice', 'Bob'])
|
|
262
|
-
})
|
|
263
|
-
|
|
264
|
-
test('reconciles arrays by index', () => {
|
|
265
|
-
const state = createStore({ items: ['a', 'b', 'c'] })
|
|
266
|
-
const calls: string[][] = []
|
|
267
|
-
effect(() => {
|
|
268
|
-
calls.push([...state.items])
|
|
269
|
-
})
|
|
270
|
-
reconcile({ items: ['a', 'X', 'c'] }, state)
|
|
271
|
-
expect(state.items[1]).toBe('X')
|
|
272
|
-
expect(calls.length).toBe(2) // initial + after reconcile
|
|
273
|
-
})
|
|
274
|
-
|
|
275
|
-
test('trims excess array elements', () => {
|
|
276
|
-
const state = createStore({ items: [1, 2, 3, 4, 5] })
|
|
277
|
-
reconcile({ items: [1, 2] }, state)
|
|
278
|
-
expect(state.items.length).toBe(2)
|
|
279
|
-
})
|
|
280
|
-
|
|
281
|
-
test('removes deleted keys', () => {
|
|
282
|
-
const state = createStore({ a: 1, b: 2, c: 3 } as Record<string, number>)
|
|
283
|
-
reconcile({ a: 1, b: 2 }, state)
|
|
284
|
-
expect('c' in state).toBe(false)
|
|
285
|
-
})
|
|
286
|
-
})
|