@pyreon/reactivity 0.18.0 → 0.19.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,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,6 @@
1
1
  import { batch, enqueuePendingNotification, isBatching } from './batch'
2
2
  import { _notifyTraceListeners, isTracing } from './debug'
3
+ import { _recordSignalWrite } from './reactive-trace'
3
4
  import { notifySubscribers, trackSubscriber } from './tracking'
4
5
 
5
6
  // Dev-time counter sink — see packages/internals/perf-harness for contract.
@@ -35,9 +36,9 @@ export interface Signal<T> {
35
36
  subscribe(listener: () => void): () => void
36
37
  /**
37
38
  * Register a direct updater — even lighter than subscribe().
38
- * Uses a flat array instead of Set. Disposal nulls the slot (no Set.delete).
39
39
  * Intended for compiler-emitted DOM bindings (_bindText, _bindDirect).
40
- * Returns a disposer that nulls the slot.
40
+ * Returns a disposer that removes the updater (O(1)); the live set
41
+ * stays bounded under register/dispose churn.
41
42
  */
42
43
  direct(updater: () => void): () => void
43
44
  /**
@@ -63,13 +64,13 @@ interface SignalFn<T> {
63
64
  _v: T
64
65
  /** @internal subscriber set (lazily allocated by trackSubscriber) */
65
66
  _s: Set<() => void> | null
66
- /** @internal direct updaters array — compiler-emitted DOM updaters (lazily allocated) */
67
- _d: ((() => void) | null)[] | null
67
+ /** @internal direct updater set — compiler-emitted DOM updaters (lazily allocated) */
68
+ _d: Set<() => void> | null
68
69
  peek(): T
69
70
  set(value: T): void
70
71
  update(fn: (current: T) => T): void
71
72
  subscribe(listener: () => void): () => void
72
- /** Register a direct updater — lighter than subscribe, uses array index disposal. */
73
+ /** Register a direct updater — lighter than subscribe; O(1) set-based disposal. */
73
74
  direct(updater: () => void): () => void
74
75
  label: string | undefined
75
76
  debug(): SignalDebugInfo<T>
@@ -87,6 +88,15 @@ function _set(this: SignalFn<unknown>, newValue: unknown) {
87
88
  _countSink.__pyreon_count__?.('reactivity.signalWrite')
88
89
  const prev = this._v
89
90
  this._v = newValue
91
+ // Dev-only bounded ring buffer of recent writes — attached to error
92
+ // reports so a crash carries the causal sequence of signal changes,
93
+ // not just the thrown value. Tree-shaken in prod via the gate.
94
+ // Deliberately separate from the `isTracing()` path below: that one
95
+ // is opt-in (requires an onSignalUpdate listener) and captures a
96
+ // stack (expensive); this is always-on in dev and intentionally
97
+ // cheap (string preview, no stack).
98
+ if (process.env.NODE_ENV !== 'production')
99
+ _recordSignalWrite(this.label, prev, newValue)
90
100
  if (isTracing()) {
91
101
  // Trace listeners are user-supplied debug code that fires on every
92
102
  // signal write. A throwing listener here would leave `_v` updated but
@@ -144,33 +154,36 @@ function _subscribe(this: SignalFn<unknown>, listener: () => void): () => void {
144
154
 
145
155
  /**
146
156
  * Register a direct updater — lighter than subscribe().
147
- * Uses a flat array instead of Set. Disposal nulls the slot (no Set.delete overhead).
148
157
  * Used by compiler-emitted _bindText/_bindDirect for zero-overhead DOM bindings.
158
+ *
159
+ * Backed by a `Set` (same as `_s`), NOT a flat array. The array form
160
+ * disposed by nulling the slot (`arr[idx] = null`) but never compacted —
161
+ * so a long-lived signal (theme/locale/auth, or a signal read inside
162
+ * `<For>` rows) bound by churning components accumulated one permanent
163
+ * dead slot per ever-mounted binding. That is an app-lifetime memory
164
+ * leak AND degrades the signal-write hot path: `notifyDirect` iterated
165
+ * O(total-ever-registered), not O(live). A Set bounds growth to the live
166
+ * set and keeps disposal + iteration O(live); the "Set.delete overhead"
167
+ * the array form optimised for is negligible against an unbounded array.
149
168
  */
150
169
  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)
170
+ if (!this._d) this._d = new Set()
171
+ const set = this._d
172
+ set.add(updater)
155
173
  return () => {
156
- arr[idx] = null
174
+ set.delete(updater)
157
175
  }
158
176
  }
159
177
 
160
178
  /**
161
- * Notify direct updaters — flat array iteration, batch-aware.
162
- * Null slots (from disposed updaters) are skipped.
179
+ * Notify direct updaters — set iteration, batch-aware. Disposed updaters
180
+ * are already absent from the set (O(1) delete on disposal).
163
181
  */
164
- function notifyDirect(updaters: ((() => void) | null)[]): void {
182
+ function notifyDirect(updaters: Set<() => void>): void {
165
183
  if (isBatching()) {
166
- for (let i = 0; i < updaters.length; i++) {
167
- const fn = updaters[i]
168
- if (fn) enqueuePendingNotification(fn)
169
- }
184
+ for (const fn of updaters) enqueuePendingNotification(fn)
170
185
  } else {
171
- for (let i = 0; i < updaters.length; i++) {
172
- updaters[i]?.()
173
- }
186
+ for (const fn of updaters) fn()
174
187
  }
175
188
  }
176
189
 
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)