@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.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +319 -30
- package/lib/types/index.d.ts +127 -3
- package/package.json +1 -1
- package/src/computed.ts +57 -16
- package/src/effect.ts +15 -1
- package/src/index.ts +16 -0
- package/src/manifest.ts +68 -1
- package/src/reactive-devtools.ts +281 -0
- package/src/reactive-trace.ts +142 -0
- package/src/reconcile.ts +12 -0
- package/src/signal.ts +40 -21
- package/src/store.ts +11 -0
- package/src/tests/computed.test.ts +31 -0
- package/src/tests/coverage-hardening.test.ts +471 -0
- package/src/tests/manifest-snapshot.test.ts +5 -3
- package/src/tests/reactive-devtools-treeshake.test.ts +48 -0
- package/src/tests/reactive-devtools.test.ts +296 -0
- package/src/tests/reactive-trace.test.ts +102 -0
- package/src/tests/reconcile-security.test.ts +45 -0
- package/src/tests/signal.test.ts +35 -9
|
@@ -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
|
+
})
|
package/src/tests/signal.test.ts
CHANGED
|
@@ -164,17 +164,25 @@ describe('signal', () => {
|
|
|
164
164
|
expect(calls3).toBe(2) // still active
|
|
165
165
|
})
|
|
166
166
|
|
|
167
|
-
test('direct updater
|
|
167
|
+
test('direct updater is removed from the set after disposal', () => {
|
|
168
168
|
const s = signal(0)
|
|
169
|
-
|
|
169
|
+
let calls = 0
|
|
170
|
+
const dispose = s.direct(() => {
|
|
171
|
+
calls++
|
|
172
|
+
})
|
|
170
173
|
|
|
171
|
-
//
|
|
172
|
-
const internal = s as unknown as { _d: (
|
|
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
|
|
177
|
+
expect(internal._d!.size).toBe(1)
|
|
178
|
+
s.set(1)
|
|
179
|
+
expect(calls).toBe(1)
|
|
175
180
|
|
|
176
181
|
dispose()
|
|
177
|
-
|
|
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
|
|
219
|
+
test('direct updater set initializes lazily', () => {
|
|
212
220
|
const s = signal(0)
|
|
213
|
-
const internal = s as unknown as { _d: (
|
|
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).
|
|
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
|
|