@pyreon/reactivity 0.1.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/src/store.ts ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * createStore — deep reactive Proxy store.
3
+ *
4
+ * Wraps a plain object/array in a Proxy that creates a fine-grained signal for
5
+ * every property. Direct mutations (`store.count++`, `store.items[0].label = "x"`)
6
+ * trigger only the signals for the mutated properties — not the whole tree.
7
+ *
8
+ * @example
9
+ * const state = createStore({ count: 0, items: [{ id: 1, text: "hello" }] })
10
+ *
11
+ * effect(() => console.log(state.count)) // tracks state.count only
12
+ * state.count++ // only the count effect re-runs
13
+ * state.items[0].text = "world" // only text-tracking effects re-run
14
+ */
15
+
16
+ import { type Signal, signal } from "./signal"
17
+
18
+ // WeakMap: raw object → its reactive proxy (ensures each raw object gets one proxy)
19
+ const proxyCache = new WeakMap<object, object>()
20
+
21
+ const IS_STORE = Symbol("pyreon.store")
22
+
23
+ /** Returns true if the value is a createStore proxy. */
24
+ export function isStore(value: unknown): boolean {
25
+ return (
26
+ value !== null &&
27
+ typeof value === "object" &&
28
+ (value as Record<symbol, unknown>)[IS_STORE] === true
29
+ )
30
+ }
31
+
32
+ /**
33
+ * Create a deep reactive store from a plain object or array.
34
+ * Returns a proxy — mutations to the proxy trigger fine-grained reactive updates.
35
+ */
36
+ export function createStore<T extends object>(initial: T): T {
37
+ return wrap(initial) as T
38
+ }
39
+
40
+ function wrap(raw: object): object {
41
+ const cached = proxyCache.get(raw)
42
+ if (cached) return cached
43
+
44
+ // Per-property signals. Lazily created on first access.
45
+ const propSignals = new Map<PropertyKey, Signal<unknown>>()
46
+ // For arrays: track length changes separately (push/pop/splice affect length)
47
+ const isArray = Array.isArray(raw)
48
+ const lengthSig = isArray ? signal((raw as unknown[]).length) : null
49
+
50
+ function getOrCreateSignal(key: PropertyKey): Signal<unknown> {
51
+ if (!propSignals.has(key)) {
52
+ propSignals.set(key, signal((raw as Record<PropertyKey, unknown>)[key]))
53
+ }
54
+ return propSignals.get(key) as Signal<unknown>
55
+ }
56
+
57
+ const proxy = new Proxy(raw, {
58
+ get(target, key) {
59
+ // Pass through the identity marker and non-string/number keys (symbols, etc.)
60
+ if (key === IS_STORE) return true
61
+ if (typeof key === "symbol") return (target as Record<symbol, unknown>)[key]
62
+
63
+ // Array length — tracked via dedicated signal for push/pop/splice reactivity
64
+ if (isArray && key === "length") return lengthSig?.()
65
+
66
+ // Non-own properties: prototype methods (forEach, map, push, …)
67
+ // These must be returned untracked so array methods work normally.
68
+ // Array methods will then go through set/get on indices via the proxy.
69
+ if (!Object.hasOwn(target, key)) {
70
+ return (target as Record<PropertyKey, unknown>)[key]
71
+ }
72
+
73
+ // Track via per-property signal
74
+ const value = getOrCreateSignal(key)()
75
+
76
+ // Deep reactivity: wrap nested objects/arrays transparently
77
+ if (value !== null && typeof value === "object") {
78
+ return wrap(value as object)
79
+ }
80
+
81
+ return value
82
+ },
83
+
84
+ set(target, key, value) {
85
+ if (typeof key === "symbol") {
86
+ ;(target as Record<symbol, unknown>)[key] = value
87
+ return true
88
+ }
89
+
90
+ const prevLength = isArray ? (target as unknown[]).length : 0
91
+ ;(target as Record<PropertyKey, unknown>)[key] = value
92
+
93
+ // Array length set directly (e.g. arr.length = 0)
94
+ if (isArray && key === "length") {
95
+ lengthSig?.set(value as number)
96
+ return true
97
+ }
98
+
99
+ // Update or create signal for this property
100
+ if (propSignals.has(key)) {
101
+ propSignals.get(key)?.set(value)
102
+ } else {
103
+ propSignals.set(key, signal(value))
104
+ }
105
+
106
+ // If array length changed (e.g. via push/splice index assignment), update it
107
+ if (isArray && (target as unknown[]).length !== prevLength) {
108
+ lengthSig?.set((target as unknown[]).length)
109
+ }
110
+
111
+ return true
112
+ },
113
+
114
+ deleteProperty(target, key) {
115
+ delete (target as Record<PropertyKey, unknown>)[key]
116
+ if (typeof key !== "symbol" && propSignals.has(key)) {
117
+ propSignals.get(key)?.set(undefined)
118
+ propSignals.delete(key)
119
+ }
120
+ if (isArray) lengthSig?.set((target as unknown[]).length)
121
+ return true
122
+ },
123
+
124
+ has(target, key) {
125
+ return Reflect.has(target, key)
126
+ },
127
+
128
+ ownKeys(target) {
129
+ return Reflect.ownKeys(target)
130
+ },
131
+
132
+ getOwnPropertyDescriptor(target, key) {
133
+ return Reflect.getOwnPropertyDescriptor(target, key)
134
+ },
135
+ })
136
+
137
+ proxyCache.set(raw, proxy)
138
+ return proxy
139
+ }
@@ -0,0 +1,69 @@
1
+ import { batch, nextTick } from "../batch"
2
+ import { effect } from "../effect"
3
+ import { signal } from "../signal"
4
+
5
+ describe("batch", () => {
6
+ test("defers notifications until end of batch", () => {
7
+ const a = signal(1)
8
+ const b = signal(2)
9
+ let runs = 0
10
+ effect(() => {
11
+ a()
12
+ b()
13
+ runs++
14
+ })
15
+ expect(runs).toBe(1) // initial run
16
+
17
+ batch(() => {
18
+ a.set(10)
19
+ b.set(20)
20
+ })
21
+ // should only re-run once despite two updates
22
+ expect(runs).toBe(2)
23
+ })
24
+
25
+ test("effect sees final values after batch", () => {
26
+ const s = signal(0)
27
+ let seen = 0
28
+ effect(() => {
29
+ seen = s()
30
+ })
31
+ batch(() => {
32
+ s.set(1)
33
+ s.set(2)
34
+ s.set(3)
35
+ })
36
+ expect(seen).toBe(3)
37
+ })
38
+
39
+ test("nested batches flush at outermost end", () => {
40
+ const s = signal(0)
41
+ let runs = 0
42
+ effect(() => {
43
+ s()
44
+ runs++
45
+ })
46
+ expect(runs).toBe(1)
47
+
48
+ batch(() => {
49
+ batch(() => {
50
+ s.set(1)
51
+ s.set(2)
52
+ })
53
+ s.set(3)
54
+ })
55
+ expect(runs).toBe(2)
56
+ })
57
+
58
+ test("nextTick resolves after microtasks flush", async () => {
59
+ const s = signal(0)
60
+ let seen = 0
61
+ effect(() => {
62
+ seen = s()
63
+ })
64
+
65
+ s.set(42)
66
+ await nextTick()
67
+ expect(seen).toBe(42)
68
+ })
69
+ })
@@ -0,0 +1,84 @@
1
+ import { _bind } from "../effect"
2
+ import { signal } from "../signal"
3
+
4
+ describe("_bind (static-dep binding)", () => {
5
+ test("runs the function on first call and tracks deps", () => {
6
+ const s = signal(0)
7
+ let runs = 0
8
+
9
+ const dispose = _bind(() => {
10
+ s()
11
+ runs++
12
+ })
13
+
14
+ expect(runs).toBe(1)
15
+
16
+ // Deps tracked on first run, re-runs on signal change
17
+ s.set(1)
18
+ expect(runs).toBe(2)
19
+
20
+ dispose()
21
+ })
22
+
23
+ test("dispose stops re-runs", () => {
24
+ const s = signal(0)
25
+ let runs = 0
26
+
27
+ const dispose = _bind(() => {
28
+ s()
29
+ runs++
30
+ })
31
+
32
+ expect(runs).toBe(1)
33
+
34
+ dispose()
35
+ s.set(1)
36
+ expect(runs).toBe(1) // no re-run
37
+ })
38
+
39
+ test("dispose is idempotent", () => {
40
+ const s = signal(0)
41
+ const dispose = _bind(() => {
42
+ s()
43
+ })
44
+
45
+ dispose()
46
+ dispose() // should not throw
47
+ })
48
+
49
+ test("does not re-run after dispose even with multiple deps", () => {
50
+ const a = signal(0)
51
+ const b = signal(0)
52
+ let runs = 0
53
+
54
+ const dispose = _bind(() => {
55
+ a()
56
+ b()
57
+ runs++
58
+ })
59
+
60
+ expect(runs).toBe(1)
61
+
62
+ dispose()
63
+ a.set(1)
64
+ b.set(1)
65
+ expect(runs).toBe(1)
66
+ })
67
+
68
+ test("disposed run callback is a no-op", () => {
69
+ const s = signal(0)
70
+ let runs = 0
71
+
72
+ const dispose = _bind(() => {
73
+ s()
74
+ runs++
75
+ })
76
+
77
+ expect(runs).toBe(1)
78
+
79
+ // Dispose then trigger — the run function should bail out
80
+ dispose()
81
+ s.set(5)
82
+ expect(runs).toBe(1)
83
+ })
84
+ })
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Targeted tests for uncovered branches across reactivity package.
3
+ */
4
+ import { Cell } from "../cell"
5
+ import { computed } from "../computed"
6
+ import { createSelector } from "../createSelector"
7
+ import { why } from "../debug"
8
+ import { _bind, effect, renderEffect } from "../effect"
9
+ import { reconcile } from "../reconcile"
10
+ import { signal } from "../signal"
11
+ import { createStore, isStore } from "../store"
12
+
13
+ // ── cell.ts branches: promote listener to Set ─────────────────────────────────
14
+
15
+ describe("Cell listener promotion", () => {
16
+ test("promotes single listener to Set when second listener added", () => {
17
+ const c = new Cell(0)
18
+ const calls: number[] = []
19
+ c.listen(() => calls.push(1))
20
+ c.listen(() => calls.push(2))
21
+ // Third listen: _s already exists (false branch of `if (!this._s)`)
22
+ c.listen(() => calls.push(3))
23
+ c.set(1)
24
+ expect(calls).toEqual([1, 2, 3])
25
+ })
26
+
27
+ test("subscribe unsubscribes single listener", () => {
28
+ const c = new Cell(0)
29
+ const calls: number[] = []
30
+ const unsub = c.subscribe(() => calls.push(1))
31
+ c.set(1)
32
+ expect(calls).toEqual([1])
33
+ unsub()
34
+ c.set(2)
35
+ // Should not fire after unsubscribe
36
+ expect(calls).toEqual([1])
37
+ })
38
+
39
+ test("subscribe unsubscribes from Set", () => {
40
+ const c = new Cell(0)
41
+ const calls: number[] = []
42
+ c.listen(() => calls.push(1))
43
+ const unsub = c.subscribe(() => calls.push(2))
44
+ c.set(1)
45
+ expect(calls).toEqual([1, 2])
46
+ unsub()
47
+ c.set(2)
48
+ expect(calls).toEqual([1, 2, 1])
49
+ })
50
+
51
+ test("promote to Set when _l was unsubscribed (null _l, null _s)", () => {
52
+ const c = new Cell(0)
53
+ const fn1 = () => {}
54
+ // subscribe sets _l, unsub sets _l to null
55
+ const unsub = c.subscribe(fn1)
56
+ unsub()
57
+ // Now _l is null and _s is null — next listen goes to fast path (!_l && !_s)
58
+ const fn2 = () => {}
59
+ c.listen(fn2)
60
+ // Add another to force promotion — _l is fn2, _s is null → promotes with _l
61
+ c.listen(() => {})
62
+ c.set(1)
63
+ })
64
+
65
+ test("double unsubscribe from single listener is safe", () => {
66
+ const c = new Cell(0)
67
+ const fn1 = () => {}
68
+ const unsub = c.subscribe(fn1)
69
+ unsub()
70
+ // Second call — _l is null, so `this._l === listener` is false
71
+ unsub()
72
+ })
73
+ })
74
+
75
+ // ── computed.ts branches ──────────────────────────────────────────────────────
76
+
77
+ describe("computed branches", () => {
78
+ test("disposed computed does not recompute", () => {
79
+ const s = signal(1)
80
+ const c = computed(() => s() * 2, { equals: Object.is })
81
+ expect(c()).toBe(2)
82
+ c.dispose()
83
+ s.set(5)
84
+ // After dispose, the computed should not update
85
+ // (it may return stale value or throw — just ensure no crash)
86
+ })
87
+
88
+ test("computed with custom equals and subscribers", () => {
89
+ const s = signal(1)
90
+ const c = computed(() => s() * 2, { equals: Object.is })
91
+ const values: number[] = []
92
+ effect(() => {
93
+ values.push(c())
94
+ })
95
+ expect(values).toEqual([2])
96
+ s.set(2)
97
+ expect(values).toEqual([2, 4])
98
+ // Same value — should not notify
99
+ s.set(2)
100
+ expect(values).toEqual([2, 4])
101
+ })
102
+
103
+ test("computed without custom equals notifies subscribers on dep change", () => {
104
+ const s = signal(1)
105
+ const c = computed(() => s() * 2)
106
+ const values: number[] = []
107
+ effect(() => {
108
+ values.push(c())
109
+ })
110
+ expect(values).toEqual([2])
111
+ s.set(2)
112
+ expect(values).toEqual([2, 4])
113
+ })
114
+ })
115
+
116
+ // ── createSelector.ts branches ────────────────────────────────────────────────
117
+
118
+ describe("createSelector branches", () => {
119
+ test("selector with no matching bucket on old value", () => {
120
+ const s = signal<string>("a")
121
+ const isSelected = createSelector(s)
122
+ // Read "a" — creates bucket for "a"
123
+ effect(() => {
124
+ isSelected("a")
125
+ })
126
+ // Change to "b" — old bucket "a" exists, new bucket "b" does not
127
+ s.set("b")
128
+ })
129
+
130
+ test("selector reuses existing host for same value", () => {
131
+ const s = signal<string>("a")
132
+ const isSelected = createSelector(s)
133
+ const results: boolean[] = []
134
+ effect(() => {
135
+ results.push(isSelected("a"))
136
+ })
137
+ // This second effect creates another subscription to same bucket
138
+ effect(() => {
139
+ results.push(isSelected("a"))
140
+ })
141
+ expect(results).toEqual([true, true])
142
+ s.set("b")
143
+ // Both should see false
144
+ expect(results).toEqual([true, true, false, false])
145
+ })
146
+
147
+ test("selector handles Object.is equality (no change)", () => {
148
+ const s = signal<string>("a")
149
+ const isSelected = createSelector(s)
150
+ let count = 0
151
+ effect(() => {
152
+ isSelected("a")
153
+ count++
154
+ })
155
+ expect(count).toBe(1)
156
+ // Same value — Object.is check should skip
157
+ s.set("a")
158
+ expect(count).toBe(1)
159
+ })
160
+
161
+ test("selector query for value with no existing bucket creates one", () => {
162
+ const s = signal<string>("a")
163
+ const isSelected = createSelector(s)
164
+ // Query outside effect — creates a bucket for "z" that has no subscribers
165
+ const result = isSelected("z")
166
+ expect(result).toBe(false)
167
+ })
168
+
169
+ test("selector change when old value has no subscriber bucket", () => {
170
+ const s = signal<string>("a")
171
+ const isSelected = createSelector(s)
172
+ // Only subscribe to "b", not "a"
173
+ effect(() => {
174
+ isSelected("b")
175
+ })
176
+ // Change from "a" to "b" — old value "a" has no bucket (never queried in effect)
177
+ s.set("b")
178
+ })
179
+ })
180
+
181
+ // ── effect.ts branches ────────────────────────────────────────────────────────
182
+
183
+ describe("effect disposed branches", () => {
184
+ test("disposed effect does not re-run", () => {
185
+ const s = signal(0)
186
+ let count = 0
187
+ const e = effect(() => {
188
+ s()
189
+ count++
190
+ })
191
+ expect(count).toBe(1)
192
+ e.dispose()
193
+ s.set(1)
194
+ expect(count).toBe(1)
195
+ })
196
+
197
+ test("disposed _bind does not re-run", () => {
198
+ const s = signal(0)
199
+ let count = 0
200
+ const dispose = _bind(() => {
201
+ s()
202
+ count++
203
+ })
204
+ expect(count).toBe(1)
205
+ dispose()
206
+ s.set(1)
207
+ expect(count).toBe(1)
208
+ // Double dispose is safe
209
+ dispose()
210
+ })
211
+
212
+ test("disposed renderEffect does not re-run", () => {
213
+ const s = signal(0)
214
+ let count = 0
215
+ const dispose = renderEffect(() => {
216
+ s()
217
+ count++
218
+ })
219
+ expect(count).toBe(1)
220
+ dispose()
221
+ s.set(1)
222
+ expect(count).toBe(1)
223
+ })
224
+ })
225
+
226
+ // ── store.ts branches ─────────────────────────────────────────────────────────
227
+
228
+ describe("store branches", () => {
229
+ test("setting symbol property", () => {
230
+ const store = createStore({ a: 1 })
231
+ const sym = Symbol("test")
232
+ ;(store as Record<symbol, unknown>)[sym] = "hello"
233
+ expect((store as Record<symbol, unknown>)[sym]).toBe("hello")
234
+ })
235
+
236
+ test("deleteProperty on store", () => {
237
+ const store = createStore<Record<string, unknown>>({ a: 1, b: 2 })
238
+ delete store.b
239
+ expect(store.b).toBeUndefined()
240
+ })
241
+
242
+ test("deleteProperty on store array", () => {
243
+ const store = createStore([1, 2, 3])
244
+ delete (store as unknown as Record<string, unknown>)["1"]
245
+ expect(store[1]).toBeUndefined()
246
+ })
247
+
248
+ test("deleteProperty on store with reactive subscriber", () => {
249
+ const store = createStore<Record<string, unknown>>({ a: 1, b: 2 })
250
+ // Read 'b' in effect to create propSignal
251
+ let val: unknown
252
+ effect(() => {
253
+ val = store.b
254
+ })
255
+ expect(val).toBe(2)
256
+ delete store.b
257
+ // Signal should be set to undefined and deleted from map
258
+ expect(val).toBeUndefined()
259
+ })
260
+ })
261
+
262
+ // ── reconcile.ts branches ─────────────────────────────────────────────────────
263
+
264
+ describe("reconcile branches", () => {
265
+ test("reconcile array with non-object source items", () => {
266
+ const store = createStore([1, 2, 3])
267
+ reconcile([4, 5], store)
268
+ expect([...store]).toEqual([4, 5])
269
+ })
270
+
271
+ test("reconcile object with raw (non-store) target value", () => {
272
+ // Create store where nested value isn't yet a store proxy
273
+ const store = createStore<Record<string, unknown>>({ a: 1 })
274
+ // Reconcile with nested object — target.a is a number (not store), so it takes the else branch
275
+ reconcile({ a: { nested: true } }, store)
276
+ expect((store.a as Record<string, unknown>).nested).toBe(true)
277
+ })
278
+
279
+ test("reconcile array with null source entries", () => {
280
+ const store = createStore([1, null, 3])
281
+ reconcile([null, 2, null], store)
282
+ expect([...store]).toEqual([null, 2, null])
283
+ })
284
+
285
+ test("reconcile object with null source values", () => {
286
+ const store = createStore<Record<string, unknown>>({ a: { x: 1 }, b: 2 })
287
+ reconcile({ a: null, b: 2 }, store)
288
+ expect(store.a).toBeNull()
289
+ })
290
+
291
+ test("reconcile array with both source and target as objects (recursive)", () => {
292
+ const store = createStore([{ a: 1 }, { b: 2 }])
293
+ reconcile([{ a: 10 }, { b: 20 }], store)
294
+ expect(store[0]?.a).toBe(10)
295
+ expect(store[1]?.b).toBe(20)
296
+ })
297
+
298
+ test("reconcile object where target has store-proxied nested object", () => {
299
+ const store = createStore<Record<string, Record<string, number>>>({ nested: { x: 1 } })
300
+ // Access nested to ensure it's proxied as store
301
+ const _val = store.nested?.x
302
+ expect(isStore(store.nested!)).toBe(true)
303
+ reconcile({ nested: { x: 99 } }, store)
304
+ expect(store.nested?.x).toBe(99)
305
+ })
306
+
307
+ test("reconcile object where target has raw (non-store) nested object", () => {
308
+ // Don't access nested, so it stays as raw object (not proxied)
309
+ const store = createStore<Record<string, Record<string, number>>>({ nested: { x: 1 } })
310
+ // nested has not been accessed via proxy, so isStore(target.nested) is false
311
+ // This should hit the `else { target[key] = sv }` branch at line 78
312
+ reconcile({ nested: { x: 99 } }, store)
313
+ expect(store.nested?.x).toBe(99)
314
+ })
315
+ })
316
+
317
+ // ── debug.ts branches ─────────────────────────────────────────────────────────
318
+
319
+ describe("debug branches", () => {
320
+ test("why with exactly 1 subscriber shows singular", async () => {
321
+ const s = signal(0, { name: "single" })
322
+ // Add exactly 1 subscriber
323
+ effect(() => {
324
+ s()
325
+ })
326
+ const logs: string[] = []
327
+ const origLog = console.log
328
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
329
+ why()
330
+ s.set(1)
331
+ // why() auto-disposes via microtask
332
+ await new Promise((r) => setTimeout(r, 10))
333
+ console.log = origLog
334
+ expect(logs.some((l) => l.includes("1 subscriber"))).toBe(true)
335
+ })
336
+
337
+ test("_notifyTraceListeners with no active listeners is noop", () => {
338
+ // When no listeners registered, this should not throw
339
+ // (tests the early return / null check)
340
+ const s = signal(0)
341
+ s.set(1) // triggers _notifyTraceListeners internally but no listeners active
342
+ })
343
+ })