@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.
Files changed (44) hide show
  1. package/package.json +1 -4
  2. package/src/batch.ts +0 -196
  3. package/src/cell.ts +0 -72
  4. package/src/computed.ts +0 -313
  5. package/src/createSelector.ts +0 -109
  6. package/src/debug.ts +0 -134
  7. package/src/effect.ts +0 -467
  8. package/src/env.d.ts +0 -6
  9. package/src/index.ts +0 -60
  10. package/src/lpih.ts +0 -227
  11. package/src/manifest.ts +0 -660
  12. package/src/reactive-devtools.ts +0 -494
  13. package/src/reactive-trace.ts +0 -142
  14. package/src/reconcile.ts +0 -118
  15. package/src/resource.ts +0 -84
  16. package/src/scope.ts +0 -123
  17. package/src/signal.ts +0 -261
  18. package/src/store.ts +0 -250
  19. package/src/tests/batch.test.ts +0 -751
  20. package/src/tests/bind.test.ts +0 -84
  21. package/src/tests/branches.test.ts +0 -343
  22. package/src/tests/cell.test.ts +0 -159
  23. package/src/tests/computed.test.ts +0 -436
  24. package/src/tests/coverage-hardening.test.ts +0 -471
  25. package/src/tests/createSelector.test.ts +0 -291
  26. package/src/tests/debug.test.ts +0 -196
  27. package/src/tests/effect.test.ts +0 -464
  28. package/src/tests/fanout-repro.test.ts +0 -179
  29. package/src/tests/lpih-source-location.test.ts +0 -277
  30. package/src/tests/lpih.test.ts +0 -351
  31. package/src/tests/manifest-snapshot.test.ts +0 -96
  32. package/src/tests/reactive-devtools-treeshake.test.ts +0 -48
  33. package/src/tests/reactive-devtools.test.ts +0 -296
  34. package/src/tests/reactive-trace.test.ts +0 -102
  35. package/src/tests/reconcile-security.test.ts +0 -45
  36. package/src/tests/resource.test.ts +0 -326
  37. package/src/tests/scope.test.ts +0 -231
  38. package/src/tests/signal.test.ts +0 -368
  39. package/src/tests/store.test.ts +0 -286
  40. package/src/tests/tracking.test.ts +0 -158
  41. package/src/tests/vue-parity.test.ts +0 -191
  42. package/src/tests/watch.test.ts +0 -246
  43. package/src/tracking.ts +0 -139
  44. package/src/watch.ts +0 -68
@@ -1,96 +0,0 @@
1
- import {
2
- renderApiReferenceEntries,
3
- renderLlmsFullSection,
4
- renderLlmsTxtLine,
5
- } from '@pyreon/manifest'
6
- import reactivityManifest from '../manifest'
7
-
8
- describe('gen-docs — reactivity snapshot', () => {
9
- it('renders @pyreon/reactivity to its expected llms.txt bullet', () => {
10
- expect(renderLlmsTxtLine(reactivityManifest)).toMatchInlineSnapshot(`"- @pyreon/reactivity — Fine-grained reactivity: signal, computed, effect, batch, onCleanup, createStore, watch, createResource, untrack. Pyreon signals are NOT \`.value\` getters (Vue ref) or \`[state, setState]\` tuples (React useState). The signal IS the function: \`count()\` reads, \`count.set(v)\` writes, \`count.update(fn)\` derives. This is the #1 confusion for developers coming from other frameworks."`)
11
- })
12
-
13
- it('renders @pyreon/reactivity to its expected llms-full.txt section — full body snapshot', () => {
14
- expect(renderLlmsFullSection(reactivityManifest)).toMatchInlineSnapshot(`
15
- "## @pyreon/reactivity — Complete API
16
-
17
- Standalone reactive primitives — no DOM, no JSX, no framework dependency. Signals are callable functions (\`count()\` to read, \`count.set(5)\` to write, \`count.update(n => n + 1)\` to derive). Subscribers tracked via \`Set<() => void>\`; batch uses pointer swap for zero-allocation grouping. Every other Pyreon package builds on this foundation but \`@pyreon/reactivity\` can be used independently in Node, Bun, or browser scripts without any framework overhead.
18
-
19
- \`\`\`typescript
20
- import { signal, computed, effect, batch, onCleanup, createStore, watch, untrack } from "@pyreon/reactivity"
21
-
22
- // signal<T>() — callable function, NOT .value getter/setter
23
- const count = signal(0)
24
- count() // read (subscribes)
25
- count.set(5) // write
26
- count.update(n => n + 1) // derive
27
- count.peek() // read WITHOUT subscribing
28
-
29
- // computed<T>() — auto-tracked, memoized
30
- const doubled = computed(() => count() * 2)
31
-
32
- // effect() — re-runs when dependencies change
33
- const dispose = effect(() => {
34
- console.log("Count:", count())
35
- onCleanup(() => console.log("cleaning up"))
36
- })
37
-
38
- // batch() — group 3+ writes into a single notification pass
39
- batch(() => {
40
- count.set(10)
41
- count.set(20) // subscribers fire once, with 20
42
- })
43
-
44
- // watch(source, callback) — explicit dependency tracking
45
- watch(() => count(), (next, prev) => {
46
- console.log(\`changed from \${prev} to \${next}\`)
47
- })
48
-
49
- // createStore() — deeply reactive object (proxy-based)
50
- const store = createStore({ todos: [{ text: 'Learn Pyreon', done: false }] })
51
- store.todos[0].done = true // fine-grained update, no immer needed
52
-
53
- // untrack() — read signals without subscribing
54
- effect(() => {
55
- const current = count()
56
- const other = untrack(() => otherSignal()) // won't re-run when otherSignal changes
57
- })
58
- \`\`\`
59
-
60
- > **Signals are callable functions**: Pyreon signals are NOT \`.value\` getters (Vue ref) or \`[state, setState]\` tuples (React useState). The signal IS the function: \`count()\` reads, \`count.set(v)\` writes, \`count.update(fn)\` derives. This is the #1 confusion for developers coming from other frameworks.
61
- >
62
- > **No dependency arrays**: \`effect()\` and \`computed()\` auto-track dependencies on each execution — no \`[dep1, dep2]\` array needed. Every signal read inside the body is a tracked dependency. This means conditional reads (\`if (cond()) { return x() }\`) only track \`x\` when \`cond()\` is true.
63
- >
64
- > **Standalone**: \`@pyreon/reactivity\` has zero dependencies. Use it in Node/Bun scripts, edge workers, or any JavaScript environment without pulling in the rest of the framework. \`@pyreon/core\` and \`@pyreon/runtime-dom\` build on it but are not required.
65
- "
66
- `)
67
- })
68
-
69
- it('renders @pyreon/reactivity to MCP api-reference entries — one per api[] item', () => {
70
- const record = renderApiReferenceEntries(reactivityManifest)
71
- // 26 entries: 8 original (signal/computed/effect/batch/onCleanup/watch/
72
- // createStore/untrack) + 1 createResource (PR #459) + 13 from M1
73
- // enrichment (renderEffect, nextTick, createSelector, cell, reconcile,
74
- // isStore, effectScope, getCurrentScope, setCurrentScope,
75
- // onSignalUpdate, inspectSignal, why, setErrorHandler) + 3 from M4
76
- // Vue parity (markRaw, shallowReactive, onScopeDispose) + 1
77
- // getReactiveTrace (reactive-trace error-report enrichment) + 2
78
- // reactive-devtools bridge (activateReactiveDevtools, getReactiveGraph).
79
- expect(Object.keys(record).length).toBe(28)
80
- expect(Object.keys(record)).toContain('reactivity/signal')
81
- expect(Object.keys(record)).toContain('reactivity/createResource')
82
- // Spot-check the flagship API — signal is the core primitive
83
- const signal = record['reactivity/signal']!
84
- expect(signal.mistakes?.split('\n').length).toBe(6)
85
- expect(signal.notes).toContain('CALLABLE FUNCTION')
86
- // Spot-check createResource has the dispose mistake (regression for H3)
87
- const resource = record['reactivity/createResource']!
88
- expect(resource.mistakes).toContain('Forgetting `dispose()`')
89
- // Spot-check newly-added entries surface their key foot-guns
90
- expect(record['reactivity/createSelector']!.mistakes).toContain(
91
- 'every row subscribes to source',
92
- )
93
- expect(record['reactivity/effectScope']!.mistakes).toContain('leak')
94
- expect(record['reactivity/cell']!.notes).toContain('NOT callable')
95
- })
96
- })
@@ -1,48 +0,0 @@
1
- /**
2
- * Tree-shake regression lock for the reactive-devtools instrumentation.
3
- *
4
- * `signal()` / `computed()` / `effect()` gained `_rdRegister` /
5
- * `_rdRecordFire` calls on their hot paths, each inside the existing
6
- * `process.env.NODE_ENV !== 'production'` gate. The framework's perf
7
- * claims rest on those calls compiling to NOTHING in production builds
8
- * (benchmarks run prod bundles). This test bundles each instrumented
9
- * module through esbuild with the prod define + minify (what every
10
- * modern bundler does for a release build) and asserts every trace of
11
- * the devtools bridge is gone — then bundles it dev-mode and asserts
12
- * the instrumentation IS present, so the test can't pass for the wrong
13
- * reason (the PR #200 bisect lesson).
14
- */
15
- import { build } from 'esbuild'
16
- import { dirname, join } from 'node:path'
17
- import { fileURLToPath } from 'node:url'
18
- import { describe, expect, it } from 'vitest'
19
-
20
- const SRC = join(dirname(fileURLToPath(import.meta.url)), '..')
21
- const MARKERS = /RecordFire|RdRegister|pxRdId|reactive-devtools/
22
-
23
- async function bundle(entry: string, env: 'production' | 'development') {
24
- const r = await build({
25
- entryPoints: [join(SRC, entry)],
26
- bundle: true,
27
- minify: true,
28
- write: false,
29
- format: 'esm',
30
- logLevel: 'silent',
31
- define: { 'process.env.NODE_ENV': JSON.stringify(env) },
32
- })
33
- return r.outputFiles[0]!.text
34
- }
35
-
36
- describe('reactive-devtools — prod tree-shake', () => {
37
- for (const entry of ['signal.ts', 'computed.ts', 'effect.ts']) {
38
- it(`${entry}: instrumentation is fully eliminated in production`, async () => {
39
- const prod = await bundle(entry, 'production')
40
- expect(prod).not.toMatch(MARKERS)
41
- })
42
-
43
- it(`${entry}: instrumentation IS present in development (anti-false-pass)`, async () => {
44
- const dev = await bundle(entry, 'development')
45
- expect(dev).toMatch(MARKERS)
46
- })
47
- }
48
- })
@@ -1,296 +0,0 @@
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
- })
@@ -1,102 +0,0 @@
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
- })
@@ -1,45 +0,0 @@
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
- })