@pyreon/reactivity 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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 nulls the slot.
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 updaters array — compiler-emitted DOM updaters (lazily allocated) */
67
- _d: ((() => void) | null)[] | null
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, uses array index disposal. */
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 arr = this._d
153
- const idx = arr.length
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
- arr[idx] = null
177
+ set.delete(updater)
157
178
  }
158
179
  }
159
180
 
160
181
  /**
161
- * Notify direct updaters — flat array iteration, batch-aware.
162
- * Null slots (from disposed updaters) are skipped.
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: ((() => void) | null)[]): void {
185
+ function notifyDirect(updaters: Set<() => void>): void {
165
186
  if (isBatching()) {
166
- for (let i = 0; i < updaters.length; i++) {
167
- const fn = updaters[i]
168
- if (fn) enqueuePendingNotification(fn)
169
- }
187
+ for (const fn of updaters) enqueuePendingNotification(fn)
170
188
  } else {
171
- for (let i = 0; i < updaters.length; i++) {
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)