@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,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactive devtools bridge — an OPT-IN, leak-free introspection layer
|
|
3
|
+
* over the live signal / computed / effect graph.
|
|
4
|
+
*
|
|
5
|
+
* Powers the `@pyreon/devtools` Signals / Graph / Effects / Console
|
|
6
|
+
* surfaces. Design constraints (mirroring `reactive-trace.ts`):
|
|
7
|
+
*
|
|
8
|
+
* - **Zero cost until attached.** Every instrumentation entry point
|
|
9
|
+
* early-returns on `!_active`. The registry is empty and no work
|
|
10
|
+
* happens until a devtools client calls `activateReactiveDevtools()`.
|
|
11
|
+
* The single call site per creation/track sits inside the existing
|
|
12
|
+
* `process.env.NODE_ENV !== 'production'` gate (tree-shaken in prod)
|
|
13
|
+
* and is structurally identical to the perf-harness counter calls
|
|
14
|
+
* and `_recordSignalWrite` already on those paths.
|
|
15
|
+
* - **No retention / no leak.** Nodes are held via `WeakRef` and
|
|
16
|
+
* pruned by a `FinalizationRegistry`. The registry never pins a
|
|
17
|
+
* signal/computed/effect alive. Edges + the fire ring buffer hold
|
|
18
|
+
* only numeric ids and primitives, never node references or values.
|
|
19
|
+
* - **Snapshot on demand.** `getReactiveGraph()` recomputes the edge
|
|
20
|
+
* set fresh from the live subscriber Sets — no incremental
|
|
21
|
+
* bookkeeping to drift out of sync with `cleanupEffect`.
|
|
22
|
+
*
|
|
23
|
+
* Names: signals carry `.label` (set explicitly or by the vite plugin's
|
|
24
|
+
* dev auto-naming). Computeds/effects have no name in the framework, so
|
|
25
|
+
* they get a stable synthetic label (`derived#12` / `effect#7`).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export type ReactiveNodeKind = 'signal' | 'derived' | 'effect'
|
|
29
|
+
|
|
30
|
+
export interface ReactiveNode {
|
|
31
|
+
id: number
|
|
32
|
+
kind: ReactiveNodeKind
|
|
33
|
+
/** Explicit `.label` for signals; synthetic (`derived#id`) otherwise. */
|
|
34
|
+
name: string
|
|
35
|
+
/** Bounded string preview of the current value (signals/derived only). */
|
|
36
|
+
value: string
|
|
37
|
+
/** Live downstream subscriber count. */
|
|
38
|
+
subscribers: number
|
|
39
|
+
/** Total times this node has fired/recomputed since activation. */
|
|
40
|
+
fires: number
|
|
41
|
+
/** `performance.now()` of the most recent fire, or null. */
|
|
42
|
+
lastFire: number | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ReactiveEdge {
|
|
46
|
+
/** Source node id (the reactive value being read). */
|
|
47
|
+
from: number
|
|
48
|
+
/** Subscriber node id (the computed/effect that read it). */
|
|
49
|
+
to: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ReactiveGraph {
|
|
53
|
+
nodes: ReactiveNode[]
|
|
54
|
+
edges: ReactiveEdge[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ReactiveFire {
|
|
58
|
+
id: number
|
|
59
|
+
/** `performance.now()` at fire time. */
|
|
60
|
+
ts: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Internal node record ─────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
interface NodeRec {
|
|
66
|
+
id: number
|
|
67
|
+
kind: ReactiveNodeKind
|
|
68
|
+
name: string
|
|
69
|
+
/** Weak handle to the read fn (signal/computed) — never pins the node. */
|
|
70
|
+
ref: WeakRef<object>
|
|
71
|
+
/** Weak handle to the subscriber-set host (signal read fn / computed host). */
|
|
72
|
+
hostRef: WeakRef<{ _s: Set<() => void> | null }> | null
|
|
73
|
+
fires: number
|
|
74
|
+
lastFire: number | null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let _active = false
|
|
78
|
+
let _nextId = 1
|
|
79
|
+
// id → record. Records are pruned by the FinalizationRegistry the moment
|
|
80
|
+
// the underlying node is GC'd, so this Map never retains a dead node.
|
|
81
|
+
const _byId = new Map<number, NodeRec>()
|
|
82
|
+
// Subscriber-callback identity → node id. Lets `getReactiveGraph()`
|
|
83
|
+
// resolve `_s` Set membership (anonymous `recompute`/`run` closures)
|
|
84
|
+
// back to graph nodes for edge extraction. A WeakMap so a disposed
|
|
85
|
+
// effect's closure doesn't keep its id mapping alive.
|
|
86
|
+
const _subId = new WeakMap<object, number>()
|
|
87
|
+
|
|
88
|
+
/** @internal — finalizer callback; prunes the record when a node is GC'd. */
|
|
89
|
+
export function _rdPrune(id: number): void {
|
|
90
|
+
_byId.delete(id)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// FinalizationRegistry is baseline since Node 14.6 / all modern browsers
|
|
94
|
+
// / Bun — the same universal-availability assumption the codebase already
|
|
95
|
+
// makes for WeakRef. No env guard (avoids an uncoverable dead branch).
|
|
96
|
+
const _finalizer = new FinalizationRegistry<number>(_rdPrune)
|
|
97
|
+
|
|
98
|
+
// Bounded fire ring buffer (Effects timeline). Same shape/rationale as
|
|
99
|
+
// reactive-trace.ts — fixed cap, primitives only, never grows.
|
|
100
|
+
const FIRE_CAP = 512
|
|
101
|
+
let _fireBuf: ReactiveFire[] | null = null
|
|
102
|
+
let _fireCount = 0
|
|
103
|
+
|
|
104
|
+
const PREVIEW_MAX = 60
|
|
105
|
+
|
|
106
|
+
function preview(v: unknown): string {
|
|
107
|
+
let s: string
|
|
108
|
+
try {
|
|
109
|
+
if (v === null) return 'null'
|
|
110
|
+
if (v === undefined) return 'undefined'
|
|
111
|
+
const t = typeof v
|
|
112
|
+
if (t === 'string') s = JSON.stringify(v) as string
|
|
113
|
+
else if (t === 'number' || t === 'boolean' || t === 'bigint') s = String(v)
|
|
114
|
+
else if (t === 'function')
|
|
115
|
+
s = `[Function ${(v as { name?: string }).name || 'anonymous'}]`
|
|
116
|
+
else if (t === 'symbol') s = (v as symbol).toString()
|
|
117
|
+
else if (Array.isArray(v)) s = `Array(${(v as unknown[]).length})`
|
|
118
|
+
else {
|
|
119
|
+
const ctor = (v as { constructor?: { name?: string } }).constructor?.name
|
|
120
|
+
let keys: string[] = []
|
|
121
|
+
try {
|
|
122
|
+
keys = Object.keys(v as object).slice(0, 3)
|
|
123
|
+
} catch {
|
|
124
|
+
keys = []
|
|
125
|
+
}
|
|
126
|
+
s = `${ctor && ctor !== 'Object' ? `${ctor} ` : ''}{${keys.join(', ')}${keys.length === 3 ? ', …' : ''}}`
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
s = '[unstringifiable]'
|
|
130
|
+
}
|
|
131
|
+
return s.length > PREVIEW_MAX ? `${s.slice(0, PREVIEW_MAX)}…` : s
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Activate the bridge. Idempotent. Called when a devtools client attaches. */
|
|
135
|
+
export function activateReactiveDevtools(): void {
|
|
136
|
+
_active = true
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Deactivate + drop all retained state. Called when the devtools client
|
|
141
|
+
* disconnects so a closed panel leaves zero residue.
|
|
142
|
+
*/
|
|
143
|
+
export function deactivateReactiveDevtools(): void {
|
|
144
|
+
_active = false
|
|
145
|
+
_byId.clear()
|
|
146
|
+
_fireBuf = null
|
|
147
|
+
_fireCount = 0
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function isReactiveDevtoolsActive(): boolean {
|
|
151
|
+
return _active
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Instrumentation entry points (called from the hot paths, but only
|
|
155
|
+
// after the existing prod gate; each is a no-op until activated) ──────
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Register a signal/computed/effect node. `host` is the object carrying
|
|
159
|
+
* the `_s` subscriber Set (the signal read fn itself, or a computed's
|
|
160
|
+
* internal host). `sub` is the notify closure (`recompute`/`run`) whose
|
|
161
|
+
* identity appears in upstream `_s` Sets — used to resolve edges.
|
|
162
|
+
*
|
|
163
|
+
* @internal
|
|
164
|
+
*/
|
|
165
|
+
export function _rdRegister(
|
|
166
|
+
node: object,
|
|
167
|
+
kind: ReactiveNodeKind,
|
|
168
|
+
host: { _s: Set<() => void> | null } | null,
|
|
169
|
+
sub: object | null,
|
|
170
|
+
label: string | undefined,
|
|
171
|
+
): number | undefined {
|
|
172
|
+
if (!_active) return undefined
|
|
173
|
+
const id = _nextId++
|
|
174
|
+
_byId.set(id, {
|
|
175
|
+
id,
|
|
176
|
+
kind,
|
|
177
|
+
name: label ?? `${kind === 'signal' ? 'signal' : kind}#${id}`,
|
|
178
|
+
ref: new WeakRef(node),
|
|
179
|
+
hostRef: host ? new WeakRef(host) : null,
|
|
180
|
+
fires: 0,
|
|
181
|
+
lastFire: null,
|
|
182
|
+
})
|
|
183
|
+
if (sub) _subId.set(sub, id)
|
|
184
|
+
_finalizer.register(node, id)
|
|
185
|
+
// Stash the id on the node so fire events correlate in O(1). Every node
|
|
186
|
+
// we register is a framework-created function/closure (signal/computed
|
|
187
|
+
// `read`, effect `run`) — always extensible, so defineProperty cannot
|
|
188
|
+
// throw here; no defensive try/catch (it would be an uncoverable dead
|
|
189
|
+
// branch).
|
|
190
|
+
Object.defineProperty(node, '__pxRdId', {
|
|
191
|
+
value: id,
|
|
192
|
+
enumerable: false,
|
|
193
|
+
configurable: true,
|
|
194
|
+
})
|
|
195
|
+
return id
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Record that a node fired (signal write / computed recompute / effect
|
|
200
|
+
* run). Bumps counters + appends to the bounded fire buffer.
|
|
201
|
+
*
|
|
202
|
+
* @internal
|
|
203
|
+
*/
|
|
204
|
+
export function _rdRecordFire(node: object): void {
|
|
205
|
+
if (!_active) return
|
|
206
|
+
const id = (node as { __pxRdId?: number }).__pxRdId
|
|
207
|
+
if (id === undefined) return
|
|
208
|
+
const rec = _byId.get(id)
|
|
209
|
+
const ts =
|
|
210
|
+
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
211
|
+
? performance.now()
|
|
212
|
+
: Date.now()
|
|
213
|
+
if (rec) {
|
|
214
|
+
rec.fires++
|
|
215
|
+
rec.lastFire = ts
|
|
216
|
+
}
|
|
217
|
+
if (_fireBuf === null) _fireBuf = new Array<ReactiveFire>(FIRE_CAP)
|
|
218
|
+
_fireBuf[_fireCount % FIRE_CAP] = { id, ts }
|
|
219
|
+
_fireCount++
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Snapshot API (consumed by the devtools hook) ─────────────────────────
|
|
223
|
+
|
|
224
|
+
function resolveSubId(sub: () => void): number | undefined {
|
|
225
|
+
const direct = (sub as { __pxRdId?: number }).__pxRdId
|
|
226
|
+
if (direct !== undefined) return direct
|
|
227
|
+
return _subId.get(sub)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Fresh snapshot of the live reactive graph. Edges are recomputed from
|
|
232
|
+
* each live node's current subscriber Set — always consistent with the
|
|
233
|
+
* framework's real subscription state, no incremental drift.
|
|
234
|
+
*/
|
|
235
|
+
export function getReactiveGraph(): ReactiveGraph {
|
|
236
|
+
const nodes: ReactiveNode[] = []
|
|
237
|
+
const edges: ReactiveEdge[] = []
|
|
238
|
+
for (const rec of _byId.values()) {
|
|
239
|
+
const node = rec.ref.deref()
|
|
240
|
+
if (!node) continue
|
|
241
|
+
const host = rec.hostRef?.deref() ?? null
|
|
242
|
+
const subs = host?._s ?? null
|
|
243
|
+
// `preview()` is total (its own try/catch returns '[unstringifiable]'),
|
|
244
|
+
// and `_v` on our registered nodes is a plain property (signal) or a
|
|
245
|
+
// getter that never throws (computed's getter routes errors through
|
|
246
|
+
// `_errorHandler` and returns the stale value). No defensive wrapper
|
|
247
|
+
// here — it would be an uncoverable dead branch.
|
|
248
|
+
const valueStr =
|
|
249
|
+
rec.kind === 'effect' ? '' : preview((node as { _v?: unknown })._v)
|
|
250
|
+
nodes.push({
|
|
251
|
+
id: rec.id,
|
|
252
|
+
kind: rec.kind,
|
|
253
|
+
name: rec.name,
|
|
254
|
+
value: valueStr,
|
|
255
|
+
subscribers: subs?.size ?? 0,
|
|
256
|
+
fires: rec.fires,
|
|
257
|
+
lastFire: rec.lastFire,
|
|
258
|
+
})
|
|
259
|
+
if (subs) {
|
|
260
|
+
for (const cb of subs) {
|
|
261
|
+
const to = resolveSubId(cb)
|
|
262
|
+
if (to !== undefined) edges.push({ from: rec.id, to })
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { nodes, edges }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Bounded recent-fire timeline (oldest → newest). Fresh copy. */
|
|
270
|
+
export function getReactiveFires(): ReactiveFire[] {
|
|
271
|
+
if (_fireBuf === null || _fireCount === 0) return []
|
|
272
|
+
if (_fireCount <= FIRE_CAP) return _fireBuf.slice(0, _fireCount)
|
|
273
|
+
const start = _fireCount % FIRE_CAP
|
|
274
|
+
const out: ReactiveFire[] = []
|
|
275
|
+
for (let i = 0; i < FIRE_CAP; i++) {
|
|
276
|
+
const e = _fireBuf[(start + i) % FIRE_CAP]
|
|
277
|
+
if (e) out.push(e)
|
|
278
|
+
}
|
|
279
|
+
return out
|
|
280
|
+
}
|
|
281
|
+
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactive trace — a bounded, dev-only ring buffer of recent signal
|
|
3
|
+
* writes. When a signal-based UI throws, the single most useful
|
|
4
|
+
* debugging question is "what reactive state changed in the run-up to
|
|
5
|
+
* the crash" — a point-in-time snapshot of every signal value can't
|
|
6
|
+
* answer that (it shows the end state, not the causal sequence). The
|
|
7
|
+
* ring buffer records the last N writes so an error report can attach
|
|
8
|
+
* the sequence that led into the bad state.
|
|
9
|
+
*
|
|
10
|
+
* Design constraints:
|
|
11
|
+
*
|
|
12
|
+
* - **Bounded memory.** Fixed-size circular buffer (`CAP` entries).
|
|
13
|
+
* Never grows. Old entries overwrite oldest-first.
|
|
14
|
+
* - **No value retention.** Stores a TRUNCATED STRING preview of
|
|
15
|
+
* prev / next, never the raw value. Holding raw references would
|
|
16
|
+
* retain large arrays / detached DOM / closures in the buffer and
|
|
17
|
+
* leak them for the buffer's lifetime. The preview is also what
|
|
18
|
+
* makes the trace safely serializable into an error report.
|
|
19
|
+
* - **Cheap.** No stack capture (that's the expensive part of the
|
|
20
|
+
* `onSignalUpdate` debug path — this is deliberately lighter so it
|
|
21
|
+
* can record every write in dev without a perf cost). One object
|
|
22
|
+
* literal + one array slot write per signal write.
|
|
23
|
+
* - **Zero production cost.** The single call site in `signal.ts`
|
|
24
|
+
* is inside the existing `process.env.NODE_ENV !== 'production'`
|
|
25
|
+
* gate, so the whole module tree-shakes out of prod bundles.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export interface ReactiveTraceEntry {
|
|
29
|
+
/** Signal `label` (set via `signal(v, { name })` or the vite plugin's dev auto-naming). `undefined` for anonymous signals. */
|
|
30
|
+
name: string | undefined
|
|
31
|
+
/** Bounded string preview of the value before the write. */
|
|
32
|
+
prev: string
|
|
33
|
+
/** Bounded string preview of the value after the write. */
|
|
34
|
+
next: string
|
|
35
|
+
/** `performance.now()` at write time (monotonic; survives clock changes). */
|
|
36
|
+
timestamp: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Ring-buffer capacity. 50 entries is enough to see the causal chain
|
|
41
|
+
* for a crash (the writes in the few ticks before the throw) without
|
|
42
|
+
* the buffer itself becoming a memory concern — each entry is a small
|
|
43
|
+
* object with two short strings.
|
|
44
|
+
*/
|
|
45
|
+
const CAP = 50
|
|
46
|
+
|
|
47
|
+
// Lazily allocated — apps that never write a signal in dev (rare) pay
|
|
48
|
+
// nothing until the first write. `_count` is the monotonic total write
|
|
49
|
+
// count; `_count % CAP` is the next slot. Reading reconstructs
|
|
50
|
+
// chronological order from the wrapped buffer.
|
|
51
|
+
let _buf: (ReactiveTraceEntry | undefined)[] | null = null
|
|
52
|
+
let _count = 0
|
|
53
|
+
|
|
54
|
+
/** Max characters of a value preview before truncation. Keeps the buffer + serialized report small. */
|
|
55
|
+
const PREVIEW_MAX = 80
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Safe, bounded stringification. Never throws (a getter or `toJSON`
|
|
59
|
+
* that throws must not break the trace recorder), never returns more
|
|
60
|
+
* than `PREVIEW_MAX` chars + an ellipsis marker.
|
|
61
|
+
*/
|
|
62
|
+
function preview(v: unknown): string {
|
|
63
|
+
let s: string
|
|
64
|
+
try {
|
|
65
|
+
if (v === null) return 'null'
|
|
66
|
+
if (v === undefined) return 'undefined'
|
|
67
|
+
const t = typeof v
|
|
68
|
+
if (t === 'string') s = JSON.stringify(v) as string
|
|
69
|
+
else if (t === 'number' || t === 'boolean' || t === 'bigint') s = String(v)
|
|
70
|
+
else if (t === 'function') s = `[Function ${(v as { name?: string }).name || 'anonymous'}]`
|
|
71
|
+
else if (t === 'symbol') s = (v as symbol).toString()
|
|
72
|
+
else if (Array.isArray(v)) s = `Array(${(v as unknown[]).length})`
|
|
73
|
+
else {
|
|
74
|
+
// Plain-ish object: show the constructor name + a shallow key
|
|
75
|
+
// hint. Avoid full JSON.stringify — it can be huge or throw on
|
|
76
|
+
// cycles / BigInt / getters.
|
|
77
|
+
const ctor = (v as { constructor?: { name?: string } }).constructor?.name
|
|
78
|
+
const keys = (() => {
|
|
79
|
+
try {
|
|
80
|
+
return Object.keys(v as object).slice(0, 4)
|
|
81
|
+
} catch {
|
|
82
|
+
return []
|
|
83
|
+
}
|
|
84
|
+
})()
|
|
85
|
+
s = `${ctor && ctor !== 'Object' ? ctor + ' ' : ''}{${keys.join(', ')}${keys.length === 4 ? ', …' : ''}}`
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
s = '[unstringifiable]'
|
|
89
|
+
}
|
|
90
|
+
return s.length > PREVIEW_MAX ? s.slice(0, PREVIEW_MAX) + '…' : s
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Record one signal write. Called from `signal.ts` `_set`, already
|
|
95
|
+
* inside the prod-gate, so this never runs in production builds.
|
|
96
|
+
*
|
|
97
|
+
* @internal
|
|
98
|
+
*/
|
|
99
|
+
export function _recordSignalWrite(name: string | undefined, prev: unknown, next: unknown): void {
|
|
100
|
+
if (_buf === null) _buf = new Array(CAP)
|
|
101
|
+
_buf[_count % CAP] = {
|
|
102
|
+
name,
|
|
103
|
+
prev: preview(prev),
|
|
104
|
+
next: preview(next),
|
|
105
|
+
timestamp:
|
|
106
|
+
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
107
|
+
? performance.now()
|
|
108
|
+
: Date.now(),
|
|
109
|
+
}
|
|
110
|
+
_count++
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returns the recorded writes in chronological order (oldest → newest),
|
|
115
|
+
* at most `CAP` entries. Empty array when nothing has been recorded.
|
|
116
|
+
* The returned array is a fresh copy — safe to retain / serialize
|
|
117
|
+
* without pinning the ring buffer.
|
|
118
|
+
*
|
|
119
|
+
* Consumed by `@pyreon/core`'s `reportError` to attach `reactiveTrace`
|
|
120
|
+
* to the error context.
|
|
121
|
+
*/
|
|
122
|
+
export function getReactiveTrace(): ReactiveTraceEntry[] {
|
|
123
|
+
if (_buf === null || _count === 0) return []
|
|
124
|
+
if (_count <= CAP) {
|
|
125
|
+
// Buffer not yet wrapped — entries 0.._count-1 are in order.
|
|
126
|
+
return _buf.slice(0, _count) as ReactiveTraceEntry[]
|
|
127
|
+
}
|
|
128
|
+
// Wrapped: oldest entry is at `_count % CAP`, walk forward CAP slots.
|
|
129
|
+
const start = _count % CAP
|
|
130
|
+
const out: ReactiveTraceEntry[] = []
|
|
131
|
+
for (let i = 0; i < CAP; i++) {
|
|
132
|
+
const e = _buf[(start + i) % CAP]
|
|
133
|
+
if (e) out.push(e)
|
|
134
|
+
}
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Clears the buffer. For test isolation; not part of the app-facing API. */
|
|
139
|
+
export function clearReactiveTrace(): void {
|
|
140
|
+
_buf = null
|
|
141
|
+
_count = 0
|
|
142
|
+
}
|
package/src/reconcile.ts
CHANGED
|
@@ -23,6 +23,16 @@ import { isStore } from './store'
|
|
|
23
23
|
|
|
24
24
|
type AnyObject = Record<PropertyKey, unknown>
|
|
25
25
|
|
|
26
|
+
// Keys that, written through the bracket-assignment paths below, would
|
|
27
|
+
// mutate Object.prototype (or a constructor's prototype) instead of the
|
|
28
|
+
// store. `reconcile` is explicitly documented for applying API responses
|
|
29
|
+
// directly (`reconcile(JSON.parse(body), store)`), and
|
|
30
|
+
// `JSON.parse('{"__proto__":{…}}')` yields an OWN enumerable `__proto__`
|
|
31
|
+
// key that `Object.keys` returns — the canonical prototype-pollution
|
|
32
|
+
// merge vector. Skip these unconditionally on both the write and the
|
|
33
|
+
// stale-key-removal pass.
|
|
34
|
+
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
35
|
+
|
|
26
36
|
export function reconcile<T extends object>(source: T, target: T): void {
|
|
27
37
|
_reconcileInner(source, target, new WeakSet())
|
|
28
38
|
}
|
|
@@ -80,6 +90,7 @@ function _reconcileObject(source: AnyObject, target: AnyObject, seen: WeakSet<ob
|
|
|
80
90
|
const targetKeys = new Set(Object.keys(target))
|
|
81
91
|
|
|
82
92
|
for (const key of sourceKeys) {
|
|
93
|
+
if (DANGEROUS_KEYS.has(key)) continue
|
|
83
94
|
const sv = source[key]
|
|
84
95
|
const tv = target[key]
|
|
85
96
|
|
|
@@ -101,6 +112,7 @@ function _reconcileObject(source: AnyObject, target: AnyObject, seen: WeakSet<ob
|
|
|
101
112
|
|
|
102
113
|
// Remove keys that no longer exist in source
|
|
103
114
|
for (const key of targetKeys) {
|
|
115
|
+
if (DANGEROUS_KEYS.has(key)) continue
|
|
104
116
|
delete target[key]
|
|
105
117
|
}
|
|
106
118
|
}
|
package/src/signal.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { batch, enqueuePendingNotification, isBatching } from './batch'
|
|
2
2
|
import { _notifyTraceListeners, isTracing } from './debug'
|
|
3
|
+
import { _rdRecordFire, _rdRegister } from './reactive-devtools'
|
|
4
|
+
import { _recordSignalWrite } from './reactive-trace'
|
|
3
5
|
import { notifySubscribers, trackSubscriber } from './tracking'
|
|
4
6
|
|
|
5
7
|
// Dev-time counter sink — see packages/internals/perf-harness for contract.
|
|
@@ -35,9 +37,9 @@ export interface Signal<T> {
|
|
|
35
37
|
subscribe(listener: () => void): () => void
|
|
36
38
|
/**
|
|
37
39
|
* Register a direct updater — even lighter than subscribe().
|
|
38
|
-
* Uses a flat array instead of Set. Disposal nulls the slot (no Set.delete).
|
|
39
40
|
* Intended for compiler-emitted DOM bindings (_bindText, _bindDirect).
|
|
40
|
-
* Returns a disposer that
|
|
41
|
+
* Returns a disposer that removes the updater (O(1)); the live set
|
|
42
|
+
* stays bounded under register/dispose churn.
|
|
41
43
|
*/
|
|
42
44
|
direct(updater: () => void): () => void
|
|
43
45
|
/**
|
|
@@ -63,13 +65,13 @@ interface SignalFn<T> {
|
|
|
63
65
|
_v: T
|
|
64
66
|
/** @internal subscriber set (lazily allocated by trackSubscriber) */
|
|
65
67
|
_s: Set<() => void> | null
|
|
66
|
-
/** @internal direct
|
|
67
|
-
_d: (
|
|
68
|
+
/** @internal direct updater set — compiler-emitted DOM updaters (lazily allocated) */
|
|
69
|
+
_d: Set<() => void> | null
|
|
68
70
|
peek(): T
|
|
69
71
|
set(value: T): void
|
|
70
72
|
update(fn: (current: T) => T): void
|
|
71
73
|
subscribe(listener: () => void): () => void
|
|
72
|
-
/** Register a direct updater — lighter than subscribe
|
|
74
|
+
/** Register a direct updater — lighter than subscribe; O(1) set-based disposal. */
|
|
73
75
|
direct(updater: () => void): () => void
|
|
74
76
|
label: string | undefined
|
|
75
77
|
debug(): SignalDebugInfo<T>
|
|
@@ -87,6 +89,17 @@ function _set(this: SignalFn<unknown>, newValue: unknown) {
|
|
|
87
89
|
_countSink.__pyreon_count__?.('reactivity.signalWrite')
|
|
88
90
|
const prev = this._v
|
|
89
91
|
this._v = newValue
|
|
92
|
+
// Dev-only bounded ring buffer of recent writes — attached to error
|
|
93
|
+
// reports so a crash carries the causal sequence of signal changes,
|
|
94
|
+
// not just the thrown value. Tree-shaken in prod via the gate.
|
|
95
|
+
// Deliberately separate from the `isTracing()` path below: that one
|
|
96
|
+
// is opt-in (requires an onSignalUpdate listener) and captures a
|
|
97
|
+
// stack (expensive); this is always-on in dev and intentionally
|
|
98
|
+
// cheap (string preview, no stack).
|
|
99
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
100
|
+
_recordSignalWrite(this.label, prev, newValue)
|
|
101
|
+
_rdRecordFire(this)
|
|
102
|
+
}
|
|
90
103
|
if (isTracing()) {
|
|
91
104
|
// Trace listeners are user-supplied debug code that fires on every
|
|
92
105
|
// signal write. A throwing listener here would leave `_v` updated but
|
|
@@ -144,33 +157,36 @@ function _subscribe(this: SignalFn<unknown>, listener: () => void): () => void {
|
|
|
144
157
|
|
|
145
158
|
/**
|
|
146
159
|
* Register a direct updater — lighter than subscribe().
|
|
147
|
-
* Uses a flat array instead of Set. Disposal nulls the slot (no Set.delete overhead).
|
|
148
160
|
* Used by compiler-emitted _bindText/_bindDirect for zero-overhead DOM bindings.
|
|
161
|
+
*
|
|
162
|
+
* Backed by a `Set` (same as `_s`), NOT a flat array. The array form
|
|
163
|
+
* disposed by nulling the slot (`arr[idx] = null`) but never compacted —
|
|
164
|
+
* so a long-lived signal (theme/locale/auth, or a signal read inside
|
|
165
|
+
* `<For>` rows) bound by churning components accumulated one permanent
|
|
166
|
+
* dead slot per ever-mounted binding. That is an app-lifetime memory
|
|
167
|
+
* leak AND degrades the signal-write hot path: `notifyDirect` iterated
|
|
168
|
+
* O(total-ever-registered), not O(live). A Set bounds growth to the live
|
|
169
|
+
* set and keeps disposal + iteration O(live); the "Set.delete overhead"
|
|
170
|
+
* the array form optimised for is negligible against an unbounded array.
|
|
149
171
|
*/
|
|
150
172
|
function _directFn(this: SignalFn<unknown>, updater: () => void): () => void {
|
|
151
|
-
if (!this._d) this._d =
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
arr.push(updater)
|
|
173
|
+
if (!this._d) this._d = new Set()
|
|
174
|
+
const set = this._d
|
|
175
|
+
set.add(updater)
|
|
155
176
|
return () => {
|
|
156
|
-
|
|
177
|
+
set.delete(updater)
|
|
157
178
|
}
|
|
158
179
|
}
|
|
159
180
|
|
|
160
181
|
/**
|
|
161
|
-
* Notify direct updaters —
|
|
162
|
-
*
|
|
182
|
+
* Notify direct updaters — set iteration, batch-aware. Disposed updaters
|
|
183
|
+
* are already absent from the set (O(1) delete on disposal).
|
|
163
184
|
*/
|
|
164
|
-
function notifyDirect(updaters: (
|
|
185
|
+
function notifyDirect(updaters: Set<() => void>): void {
|
|
165
186
|
if (isBatching()) {
|
|
166
|
-
for (
|
|
167
|
-
const fn = updaters[i]
|
|
168
|
-
if (fn) enqueuePendingNotification(fn)
|
|
169
|
-
}
|
|
187
|
+
for (const fn of updaters) enqueuePendingNotification(fn)
|
|
170
188
|
} else {
|
|
171
|
-
for (
|
|
172
|
-
updaters[i]?.()
|
|
173
|
-
}
|
|
189
|
+
for (const fn of updaters) fn()
|
|
174
190
|
}
|
|
175
191
|
}
|
|
176
192
|
|
|
@@ -218,5 +234,8 @@ export function signal<T>(initialValue: T, options?: SignalOptions): Signal<T> {
|
|
|
218
234
|
read.debug = _debug as () => SignalDebugInfo<T>
|
|
219
235
|
read.label = options?.name
|
|
220
236
|
|
|
237
|
+
if (process.env.NODE_ENV !== 'production')
|
|
238
|
+
_rdRegister(read, 'signal', read, null, read.label)
|
|
239
|
+
|
|
221
240
|
return read as unknown as Signal<T>
|
|
222
241
|
}
|
package/src/store.ts
CHANGED
|
@@ -179,6 +179,17 @@ function wrap(raw: object, shallow: boolean): object {
|
|
|
179
179
|
return true
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
// Defense-in-depth against prototype pollution: a bare
|
|
183
|
+
// `target.__proto__ = obj` here would mutate the store object's
|
|
184
|
+
// prototype rather than set a property. `reconcile()` already
|
|
185
|
+
// filters these, but the proxy is the public write surface
|
|
186
|
+
// (`store.__proto__ = …`, spread of untrusted data) so guard here
|
|
187
|
+
// too. Silently ignore — assigning these through a store is never
|
|
188
|
+
// a legitimate operation.
|
|
189
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
190
|
+
return true
|
|
191
|
+
}
|
|
192
|
+
|
|
182
193
|
const prevLength = isArray ? (target as unknown[]).length : 0
|
|
183
194
|
;(target as Record<PropertyKey, unknown>)[key] = value
|
|
184
195
|
|
|
@@ -198,6 +198,37 @@ describe('computed', () => {
|
|
|
198
198
|
expect(called).toBe(2) // equals suppresses
|
|
199
199
|
})
|
|
200
200
|
|
|
201
|
+
test('.direct() registrations do not accumulate under churn (bounded, like signal._d)', () => {
|
|
202
|
+
// Regression for the never-compacted-array leak: a long-lived
|
|
203
|
+
// computed whose direct updaters register+dispose repeatedly (e.g.
|
|
204
|
+
// <For> rows re-mounting) must keep its live set bounded to LIVE
|
|
205
|
+
// registrations, not grow one permanent dead slot per ever-
|
|
206
|
+
// registered binding (which also made `recompute` O(total-ever)).
|
|
207
|
+
const s = signal(0)
|
|
208
|
+
const c = computed(() => s() * 2)
|
|
209
|
+
c() // initialize
|
|
210
|
+
const internal = c as unknown as { _d: Set<() => void> | null }
|
|
211
|
+
|
|
212
|
+
for (let i = 0; i < 10_000; i++) {
|
|
213
|
+
const dispose = c.direct(() => {})
|
|
214
|
+
dispose()
|
|
215
|
+
}
|
|
216
|
+
expect(internal._d!.size).toBe(0)
|
|
217
|
+
|
|
218
|
+
// One live binding survives → notify/iterate cost is O(live), not 10k.
|
|
219
|
+
let fired = 0
|
|
220
|
+
const dispose = c.direct(() => {
|
|
221
|
+
fired++
|
|
222
|
+
})
|
|
223
|
+
expect(internal._d!.size).toBe(1)
|
|
224
|
+
s.set(1)
|
|
225
|
+
expect(fired).toBe(1)
|
|
226
|
+
dispose()
|
|
227
|
+
expect(internal._d!.size).toBe(0)
|
|
228
|
+
s.set(2)
|
|
229
|
+
expect(fired).toBe(1) // disposed updater not invoked
|
|
230
|
+
})
|
|
231
|
+
|
|
201
232
|
describe('_v with equals after disposal', () => {
|
|
202
233
|
test('_v returns last cached value after dispose()', () => {
|
|
203
234
|
const s = signal(5)
|