@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/LICENSE +21 -0
- package/README.md +73 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +838 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +725 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +342 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +40 -0
- package/src/batch.ts +44 -0
- package/src/cell.ts +71 -0
- package/src/computed.ts +71 -0
- package/src/createSelector.ts +56 -0
- package/src/debug.ts +134 -0
- package/src/effect.ts +152 -0
- package/src/index.ts +15 -0
- package/src/reconcile.ts +98 -0
- package/src/resource.ts +66 -0
- package/src/scope.ts +80 -0
- package/src/signal.ts +125 -0
- package/src/store.ts +139 -0
- package/src/tests/batch.test.ts +69 -0
- package/src/tests/bind.test.ts +84 -0
- package/src/tests/branches.test.ts +343 -0
- package/src/tests/cell.test.ts +111 -0
- package/src/tests/computed.test.ts +146 -0
- package/src/tests/createSelector.test.ts +119 -0
- package/src/tests/debug.test.ts +196 -0
- package/src/tests/effect.test.ts +256 -0
- package/src/tests/resource.test.ts +133 -0
- package/src/tests/scope.test.ts +202 -0
- package/src/tests/signal.test.ts +120 -0
- package/src/tests/store.test.ts +136 -0
- package/src/tests/tracking.test.ts +158 -0
- package/src/tests/watch.test.ts +146 -0
- package/src/tracking.ts +103 -0
- package/src/watch.ts +69 -0
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
|
+
})
|