@pyreon/reactivity 0.18.0 → 0.20.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.
@@ -0,0 +1,296 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { computed } from '../computed'
3
+ import { effect } from '../effect'
4
+ import {
5
+ _rdPrune,
6
+ activateReactiveDevtools,
7
+ deactivateReactiveDevtools,
8
+ getReactiveFires,
9
+ getReactiveGraph,
10
+ isReactiveDevtoolsActive,
11
+ } from '../reactive-devtools'
12
+ import { signal } from '../signal'
13
+
14
+ afterEach(() => {
15
+ deactivateReactiveDevtools()
16
+ })
17
+
18
+ describe('reactive-devtools — opt-in contract', () => {
19
+ it('is inactive by default and tracks nothing until activated', () => {
20
+ expect(isReactiveDevtoolsActive()).toBe(false)
21
+ const s = signal(1)
22
+ s.set(2)
23
+ const c = computed(() => s() + 1)
24
+ c()
25
+ expect(getReactiveGraph().nodes).toEqual([])
26
+ expect(getReactiveFires()).toEqual([])
27
+ })
28
+
29
+ it('activate() then deactivate() is idempotent and clears state', () => {
30
+ activateReactiveDevtools()
31
+ expect(isReactiveDevtoolsActive()).toBe(true)
32
+ activateReactiveDevtools() // idempotent
33
+ expect(isReactiveDevtoolsActive()).toBe(true)
34
+ const s = signal(0, { name: 'x' })
35
+ s()
36
+ expect(getReactiveGraph().nodes.length).toBe(1)
37
+ deactivateReactiveDevtools()
38
+ expect(isReactiveDevtoolsActive()).toBe(false)
39
+ expect(getReactiveGraph().nodes).toEqual([])
40
+ })
41
+ })
42
+
43
+ describe('reactive-devtools — node registry', () => {
44
+ it('registers a named signal with kind + value preview', () => {
45
+ activateReactiveDevtools()
46
+ const count = signal(42, { name: 'count' })
47
+ void count()
48
+ const g = getReactiveGraph()
49
+ const node = g.nodes.find((n) => n.name === 'count')
50
+ expect(node).toBeDefined()
51
+ expect(node!.kind).toBe('signal')
52
+ expect(node!.value).toBe('42')
53
+ })
54
+
55
+ it('synthesizes a label for anonymous derived/effect nodes', () => {
56
+ activateReactiveDevtools()
57
+ const s = signal(1)
58
+ const d = computed(() => s() * 2)
59
+ void d()
60
+ effect(() => void s())
61
+ const g = getReactiveGraph()
62
+ expect(g.nodes.some((n) => n.kind === 'derived' && /^derived#\d+$/.test(n.name))).toBe(true)
63
+ expect(g.nodes.some((n) => n.kind === 'effect' && /^effect#\d+$/.test(n.name))).toBe(true)
64
+ })
65
+
66
+ it('previews non-primitive signal values without throwing', () => {
67
+ activateReactiveDevtools()
68
+ const obj = signal({ a: 1, b: 2 }, { name: 'o' })
69
+ void obj()
70
+ const arr = signal([1, 2, 3], { name: 'arr' })
71
+ void arr()
72
+ const g = getReactiveGraph()
73
+ expect(g.nodes.find((n) => n.name === 'o')!.value).toContain('{')
74
+ expect(g.nodes.find((n) => n.name === 'arr')!.value).toBe('Array(3)')
75
+ })
76
+ })
77
+
78
+ describe('reactive-devtools — edges from live subscriber sets', () => {
79
+ it('captures signal → derived → effect edges', () => {
80
+ activateReactiveDevtools()
81
+ const s = signal(1, { name: 's' })
82
+ const d = computed(() => s() + 1)
83
+ let seen = 0
84
+ effect(() => {
85
+ seen = d()
86
+ })
87
+ expect(seen).toBe(2)
88
+
89
+ const g = getReactiveGraph()
90
+ const sId = g.nodes.find((n) => n.name === 's')!.id
91
+ const dNode = g.nodes.find((n) => n.kind === 'derived')!
92
+ const eNode = g.nodes.find((n) => n.kind === 'effect')!
93
+
94
+ // s is read by d; d is read by the effect.
95
+ expect(g.edges).toContainEqual({ from: sId, to: dNode.id })
96
+ expect(g.edges).toContainEqual({ from: dNode.id, to: eNode.id })
97
+ })
98
+
99
+ it('reflects subscriber count + reacts to writes (fires + lastFire)', () => {
100
+ activateReactiveDevtools()
101
+ const s = signal(0, { name: 'live' })
102
+ effect(() => void s())
103
+ s.set(1)
104
+ s.set(2)
105
+ const node = getReactiveGraph().nodes.find((n) => n.name === 'live')!
106
+ expect(node.subscribers).toBe(1)
107
+ expect(node.fires).toBe(2)
108
+ expect(node.lastFire).not.toBeNull()
109
+ })
110
+ })
111
+
112
+ describe('reactive-devtools — value preview branches', () => {
113
+ it('previews every primitive + edge shape', () => {
114
+ activateReactiveDevtools()
115
+ const cases: [string, unknown, (v: string) => void][] = [
116
+ ['s_str', 'hello', (v) => expect(v).toBe('"hello"')],
117
+ ['s_num', 7, (v) => expect(v).toBe('7')],
118
+ ['s_bool', true, (v) => expect(v).toBe('true')],
119
+ ['s_big', 10n, (v) => expect(v).toBe('10')],
120
+ ['s_null', null, (v) => expect(v).toBe('null')],
121
+ ['s_undef', undefined, (v) => expect(v).toBe('undefined')],
122
+ ['s_sym', Symbol('z'), (v) => expect(v).toContain('Symbol')],
123
+ ['s_fn', function named() {}, (v) => expect(v).toContain('[Function named]')],
124
+ [
125
+ 's_long',
126
+ 'x'.repeat(200),
127
+ (v) => expect(v.endsWith('…') && v.length <= 61).toBe(true),
128
+ ],
129
+ ]
130
+ for (const [name, val] of cases) {
131
+ const s = signal(val, { name })
132
+ void s()
133
+ }
134
+ const g = getReactiveGraph()
135
+ for (const [name, , assertFn] of cases) {
136
+ assertFn(g.nodes.find((n) => n.name === name)!.value)
137
+ }
138
+ })
139
+
140
+ it('never throws on a value whose property access throws', () => {
141
+ activateReactiveDevtools()
142
+ const hostile = new Proxy(
143
+ {},
144
+ {
145
+ ownKeys() {
146
+ throw new Error('boom')
147
+ },
148
+ get() {
149
+ throw new Error('boom')
150
+ },
151
+ },
152
+ )
153
+ const s = signal(hostile, { name: 'hostile' })
154
+ void s()
155
+ const node = getReactiveGraph().nodes.find((n) => n.name === 'hostile')!
156
+ expect(typeof node.value).toBe('string')
157
+ })
158
+
159
+ it('handles a value whose ownKeys throws but ctor read succeeds', () => {
160
+ activateReactiveDevtools()
161
+ // `.constructor` resolves fine (default get), but Object.keys() trips
162
+ // the inner keys try/catch.
163
+ const keysHostile = new Proxy(
164
+ {},
165
+ {
166
+ ownKeys() {
167
+ throw new Error('no keys')
168
+ },
169
+ },
170
+ )
171
+ const s = signal(keysHostile, { name: 'kh' })
172
+ void s()
173
+ const node = getReactiveGraph().nodes.find((n) => n.name === 'kh')!
174
+ expect(node.value).toBe('{}')
175
+ })
176
+
177
+ it('effect nodes carry no value preview', () => {
178
+ activateReactiveDevtools()
179
+ const s = signal(1)
180
+ effect(() => void s())
181
+ const eff = getReactiveGraph().nodes.find((n) => n.kind === 'effect')!
182
+ expect(eff.value).toBe('')
183
+ })
184
+ })
185
+
186
+ describe('reactive-devtools — resilience', () => {
187
+ it('a stale __pxRdId (registry cleared, node re-fires) is buffered, not crashed', () => {
188
+ activateReactiveDevtools()
189
+ const s = signal(0, { name: 'stale' })
190
+ void s()
191
+ deactivateReactiveDevtools()
192
+ // Re-activate: _byId is empty but `s` still carries its old __pxRdId.
193
+ activateReactiveDevtools()
194
+ expect(() => s.set(1)).not.toThrow()
195
+ // Fire is still buffered even though no record exists for the id.
196
+ expect(getReactiveFires().length).toBe(1)
197
+ // …and it does not appear as a node (record was cleared).
198
+ expect(getReactiveGraph().nodes.find((n) => n.name === 'stale')).toBeUndefined()
199
+ })
200
+
201
+ it('getReactiveFires is empty before any fire', () => {
202
+ activateReactiveDevtools()
203
+ expect(getReactiveFires()).toEqual([])
204
+ })
205
+
206
+ it('_rdPrune removes a record (FinalizationRegistry callback path)', () => {
207
+ activateReactiveDevtools()
208
+ const s = signal(1, { name: 'pruneme' })
209
+ void s()
210
+ const before = getReactiveGraph().nodes.find((n) => n.name === 'pruneme')
211
+ expect(before).toBeDefined()
212
+ _rdPrune(before!.id)
213
+ expect(getReactiveGraph().nodes.find((n) => n.name === 'pruneme')).toBeUndefined()
214
+ })
215
+ })
216
+
217
+ describe('reactive-devtools — bounded fire timeline', () => {
218
+ it('records signal writes + computed recomputes in order', () => {
219
+ activateReactiveDevtools()
220
+ const s = signal(0, { name: 't' })
221
+ const d = computed(() => s() + 1)
222
+ effect(() => void d())
223
+ s.set(1)
224
+ s.set(2)
225
+ const fires = getReactiveFires()
226
+ expect(fires.length).toBeGreaterThanOrEqual(2)
227
+ // monotonic, non-decreasing timestamps
228
+ for (let i = 1; i < fires.length; i++) {
229
+ expect(fires[i]!.ts).toBeGreaterThanOrEqual(fires[i - 1]!.ts)
230
+ }
231
+ })
232
+
233
+ it('caps the ring buffer (no unbounded growth)', () => {
234
+ activateReactiveDevtools()
235
+ const s = signal(0, { name: 'spin' })
236
+ for (let i = 1; i <= 700; i++) s.set(i)
237
+ expect(getReactiveFires().length).toBeLessThanOrEqual(512)
238
+ })
239
+ })
240
+
241
+ describe('reactive-devtools — preview() edge branches (coverage lock)', () => {
242
+ // Lifts reactive-devtools.ts off the 8 uncovered `preview()` /
243
+ // performance-fallback branches that landed with #703 and dragged
244
+ // @pyreon/reactivity global branch coverage to 89.75% (< the 90%
245
+ // gate). With these: 90.7% (478/527) — the Coverage CI gate passes.
246
+ const valueOf = (name: string) =>
247
+ getReactiveGraph().nodes.find((n) => n.name === name)?.value
248
+
249
+ it('anonymous function → [Function anonymous] (|| fallback arm)', () => {
250
+ activateReactiveDevtools()
251
+ const s = signal<unknown>((() => () => {})(), { name: 'anonFn' })
252
+ void s()
253
+ expect(valueOf('anonFn')).toBe('[Function anonymous]')
254
+ })
255
+
256
+ it('plain object whose ctor IS Object → no ctor prefix (empty-arm)', () => {
257
+ activateReactiveDevtools()
258
+ const s = signal<unknown>({ a: 1 }, { name: 'plainObj' })
259
+ void s()
260
+ expect(valueOf('plainObj')).toBe('{a}')
261
+ })
262
+
263
+ it('object with more than 3 keys → truncates with ellipsis', () => {
264
+ activateReactiveDevtools()
265
+ const s = signal<unknown>({ a: 1, b: 2, c: 3, d: 4 }, { name: 'bigObj' })
266
+ void s()
267
+ expect(valueOf('bigObj')).toBe('{a, b, c, …}')
268
+ })
269
+
270
+ it('classed object → keeps the ctor prefix (truthy arm)', () => {
271
+ class Box {
272
+ x = 1
273
+ }
274
+ activateReactiveDevtools()
275
+ const s = signal<unknown>(new Box(), { name: 'boxObj' })
276
+ void s()
277
+ expect(valueOf('boxObj')).toBe('Box {x}')
278
+ })
279
+
280
+ it('records the Date.now fallback when performance is unavailable', () => {
281
+ const realPerf = globalThis.performance
282
+ try {
283
+ // Exercise the `typeof performance === 'undefined'` defensive arm.
284
+ delete (globalThis as { performance?: unknown }).performance
285
+ activateReactiveDevtools()
286
+ const s = signal(0, { name: 'noPerf' })
287
+ void s()
288
+ expect(() => s.set(1)).not.toThrow()
289
+ const fires = getReactiveFires()
290
+ expect(fires.length).toBeGreaterThanOrEqual(1)
291
+ expect(typeof fires[0]!.ts).toBe('number')
292
+ } finally {
293
+ ;(globalThis as { performance?: unknown }).performance = realPerf
294
+ }
295
+ })
296
+ })
@@ -0,0 +1,102 @@
1
+ import { clearReactiveTrace, getReactiveTrace } from '../reactive-trace'
2
+ import { signal } from '../signal'
3
+
4
+ describe('reactive-trace ring buffer', () => {
5
+ beforeEach(() => clearReactiveTrace())
6
+
7
+ test('empty before any write', () => {
8
+ expect(getReactiveTrace()).toEqual([])
9
+ })
10
+
11
+ test('records writes chronologically with name + previews', () => {
12
+ const count = signal(0, { name: 'count' })
13
+ count.set(1)
14
+ count.set(2)
15
+ const trace = getReactiveTrace()
16
+ expect(trace).toHaveLength(2)
17
+ expect(trace[0]).toMatchObject({ name: 'count', prev: '0', next: '1' })
18
+ expect(trace[1]).toMatchObject({ name: 'count', prev: '1', next: '2' })
19
+ expect(trace[0]!.timestamp).toBeTypeOf('number')
20
+ })
21
+
22
+ test('no-op writes (Object.is equal) are not recorded', () => {
23
+ const s = signal(5, { name: 's' })
24
+ s.set(5) // same value — _set returns early before the recorder
25
+ expect(getReactiveTrace()).toEqual([])
26
+ s.set(6)
27
+ expect(getReactiveTrace()).toHaveLength(1)
28
+ })
29
+
30
+ test('anonymous signals record name: undefined', () => {
31
+ const s = signal('a')
32
+ s.set('b')
33
+ const trace = getReactiveTrace()
34
+ expect(trace[0]!.name).toBeUndefined()
35
+ expect(trace[0]).toMatchObject({ prev: '"a"', next: '"b"' })
36
+ })
37
+
38
+ test('previews are bounded and never throw on hostile values', () => {
39
+ const s = signal<unknown>(null, { name: 'hostile' })
40
+ // getter that throws
41
+ const evil = {
42
+ get boom() {
43
+ throw new Error('nope')
44
+ },
45
+ }
46
+ s.set(evil)
47
+ // circular
48
+ const circ: Record<string, unknown> = {}
49
+ circ.self = circ
50
+ s.set(circ)
51
+ // huge string
52
+ s.set('x'.repeat(5000))
53
+ const trace = getReactiveTrace()
54
+ expect(trace).toHaveLength(3)
55
+ for (const e of trace) {
56
+ // Each preview stays bounded (PREVIEW_MAX=80 + ellipsis).
57
+ expect(e.prev.length).toBeLessThanOrEqual(81)
58
+ expect(e.next.length).toBeLessThanOrEqual(81)
59
+ }
60
+ // The huge-string write got truncated with an ellipsis marker.
61
+ expect(trace[2]!.next.endsWith('…')).toBe(true)
62
+ })
63
+
64
+ test('object preview shows constructor + shallow keys, not full JSON', () => {
65
+ class Box {
66
+ a = 1
67
+ b = 2
68
+ }
69
+ const s = signal<unknown>(0, { name: 'o' })
70
+ s.set(new Box())
71
+ s.set({ x: 1, y: 2, z: 3 })
72
+ const trace = getReactiveTrace()
73
+ expect(trace[0]!.next).toContain('Box')
74
+ expect(trace[1]!.next).toContain('x, y, z')
75
+ })
76
+
77
+ test('ring buffer is bounded at 50 — oldest evicted, order preserved', () => {
78
+ const s = signal(0, { name: 'ring' })
79
+ for (let i = 1; i <= 70; i++) s.set(i)
80
+ const trace = getReactiveTrace()
81
+ expect(trace).toHaveLength(50)
82
+ // Oldest surviving write is the 21st (writes 1..20 evicted).
83
+ expect(trace[0]).toMatchObject({ prev: '20', next: '21' })
84
+ expect(trace[49]).toMatchObject({ prev: '69', next: '70' })
85
+ })
86
+
87
+ test('returned array is a copy — mutating it does not affect the buffer', () => {
88
+ const s = signal(0, { name: 'c' })
89
+ s.set(1)
90
+ const a = getReactiveTrace()
91
+ a.push({ name: 'fake', prev: 'x', next: 'y', timestamp: 0 })
92
+ expect(getReactiveTrace()).toHaveLength(1)
93
+ })
94
+
95
+ test('clearReactiveTrace resets to empty', () => {
96
+ const s = signal(0, { name: 'c' })
97
+ s.set(1)
98
+ expect(getReactiveTrace()).toHaveLength(1)
99
+ clearReactiveTrace()
100
+ expect(getReactiveTrace()).toEqual([])
101
+ })
102
+ })
@@ -0,0 +1,45 @@
1
+ import { reconcile } from '../reconcile'
2
+ import { createStore } from '../store'
3
+
4
+ // Regression: prototype-pollution hardening for the documented
5
+ // "apply an untrusted API response straight into a store" path.
6
+ // `JSON.parse('{"__proto__":{…}}')` produces an OWN enumerable
7
+ // `__proto__` key that `Object.keys` returns — the canonical
8
+ // merge-path pollution vector. Both `reconcile()` and the store
9
+ // proxy `set` trap must refuse the dangerous keys.
10
+ describe('reconcile / store — prototype pollution hardening', () => {
11
+ afterEach(() => {
12
+ // Scrub any pollution so a failure here can't cascade into other suites.
13
+ delete (Object.prototype as Record<string, unknown>).polluted
14
+ delete (Object.prototype as Record<string, unknown>).isAdmin
15
+ })
16
+
17
+ test('reconcile ignores a JSON __proto__ payload (no Object.prototype mutation)', () => {
18
+ const state = createStore<Record<string, unknown>>({ user: { name: 'a' } })
19
+ const malicious = JSON.parse('{"__proto__":{"polluted":"yes"},"user":{"name":"b"}}')
20
+
21
+ reconcile(malicious, state)
22
+
23
+ expect(({} as Record<string, unknown>).polluted).toBeUndefined()
24
+ expect(Object.getPrototypeOf(state)).toBe(Object.prototype)
25
+ // Legitimate data still reconciled.
26
+ expect((state.user as { name: string }).name).toBe('b')
27
+ })
28
+
29
+ test('reconcile ignores nested constructor.prototype payload', () => {
30
+ const state = createStore<Record<string, unknown>>({})
31
+ const malicious = JSON.parse('{"constructor":{"prototype":{"isAdmin":true}}}')
32
+
33
+ reconcile(malicious, state)
34
+
35
+ expect(({} as Record<string, unknown>).isAdmin).toBeUndefined()
36
+ })
37
+
38
+ test('store proxy set trap refuses __proto__ assignment', () => {
39
+ const state = createStore<Record<string, unknown>>({})
40
+ ;(state as Record<string, unknown>).__proto__ = { polluted: 'yes' }
41
+
42
+ expect(({} as Record<string, unknown>).polluted).toBeUndefined()
43
+ expect(Object.getPrototypeOf(state)).toBe(Object.prototype)
44
+ })
45
+ })
@@ -164,17 +164,25 @@ describe('signal', () => {
164
164
  expect(calls3).toBe(2) // still active
165
165
  })
166
166
 
167
- test('direct updater slot is null after disposal', () => {
167
+ test('direct updater is removed from the set after disposal', () => {
168
168
  const s = signal(0)
169
- const dispose = s.direct(() => {})
169
+ let calls = 0
170
+ const dispose = s.direct(() => {
171
+ calls++
172
+ })
170
173
 
171
- // Access internal _d array via cast
172
- const internal = s as unknown as { _d: ((() => void) | null)[] | null }
174
+ // Internal `_d` is a Set (not an unbounded array see signal.ts).
175
+ const internal = s as unknown as { _d: Set<() => void> | null }
173
176
  expect(internal._d).not.toBeNull()
174
- expect(internal._d![0]).toBeTypeOf('function')
177
+ expect(internal._d!.size).toBe(1)
178
+ s.set(1)
179
+ expect(calls).toBe(1)
175
180
 
176
181
  dispose()
177
- expect(internal._d![0]).toBeNull()
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
178
186
  })
179
187
  })
180
188
 
@@ -208,13 +216,31 @@ describe('signal', () => {
208
216
  expect(calls).toBe(1)
209
217
  })
210
218
 
211
- test('direct updater with no prior direct array initializes lazily', () => {
219
+ test('direct updater set initializes lazily', () => {
212
220
  const s = signal(0)
213
- const internal = s as unknown as { _d: ((() => void) | null)[] | null }
221
+ const internal = s as unknown as { _d: Set<() => void> | null }
214
222
  expect(internal._d).toBeNull()
215
223
  s.direct(() => {})
216
224
  expect(internal._d).not.toBeNull()
217
- expect(internal._d).toHaveLength(1)
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()
218
244
  })
219
245
  })
220
246