@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.
- package/package.json +1 -4
- package/src/batch.ts +0 -196
- package/src/cell.ts +0 -72
- package/src/computed.ts +0 -313
- package/src/createSelector.ts +0 -109
- package/src/debug.ts +0 -134
- package/src/effect.ts +0 -467
- package/src/env.d.ts +0 -6
- package/src/index.ts +0 -60
- package/src/lpih.ts +0 -227
- package/src/manifest.ts +0 -660
- package/src/reactive-devtools.ts +0 -494
- package/src/reactive-trace.ts +0 -142
- package/src/reconcile.ts +0 -118
- package/src/resource.ts +0 -84
- package/src/scope.ts +0 -123
- package/src/signal.ts +0 -261
- package/src/store.ts +0 -250
- package/src/tests/batch.test.ts +0 -751
- package/src/tests/bind.test.ts +0 -84
- package/src/tests/branches.test.ts +0 -343
- package/src/tests/cell.test.ts +0 -159
- package/src/tests/computed.test.ts +0 -436
- package/src/tests/coverage-hardening.test.ts +0 -471
- package/src/tests/createSelector.test.ts +0 -291
- package/src/tests/debug.test.ts +0 -196
- package/src/tests/effect.test.ts +0 -464
- package/src/tests/fanout-repro.test.ts +0 -179
- package/src/tests/lpih-source-location.test.ts +0 -277
- package/src/tests/lpih.test.ts +0 -351
- package/src/tests/manifest-snapshot.test.ts +0 -96
- package/src/tests/reactive-devtools-treeshake.test.ts +0 -48
- package/src/tests/reactive-devtools.test.ts +0 -296
- package/src/tests/reactive-trace.test.ts +0 -102
- package/src/tests/reconcile-security.test.ts +0 -45
- package/src/tests/resource.test.ts +0 -326
- package/src/tests/scope.test.ts +0 -231
- package/src/tests/signal.test.ts +0 -368
- package/src/tests/store.test.ts +0 -286
- package/src/tests/tracking.test.ts +0 -158
- package/src/tests/vue-parity.test.ts +0 -191
- package/src/tests/watch.test.ts +0 -246
- package/src/tracking.ts +0 -139
- 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
|
-
})
|