@pyreon/reactivity 0.14.0 → 0.15.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/src/effect.ts CHANGED
@@ -2,15 +2,47 @@ import { getCurrentScope } from './scope'
2
2
  import { _restoreActiveEffect, _setActiveEffect, setDepsCollector, withTracking } from './tracking'
3
3
 
4
4
  // Dev-time counter sink — see packages/internals/perf-harness for contract.
5
- interface ViteMeta {
6
- readonly env?: { readonly DEV?: boolean }
7
- }
8
5
  const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
9
6
 
10
7
  export interface Effect {
11
8
  dispose(): void
12
9
  }
13
10
 
11
+ // ─── Effect-scoped snapshot capture (DI from `@pyreon/core`) ─────────────────
12
+ //
13
+ // Effects re-run reactively in response to signal changes. When that re-run
14
+ // happens AFTER the synchronous mount that set the effect up, the surrounding
15
+ // context stack (from `@pyreon/core`'s provide() calls) may have been
16
+ // destructively truncated by `mountReactive`'s `restoreContextStack` cleanup.
17
+ // Without restoring the captured context, signal-driven re-runs of `_bind` /
18
+ // `renderEffect` / `effect` see a half-empty stack and `useContext()` falls
19
+ // back to the default value — silently breaking provider-backed APIs like
20
+ // `useMode()`, `useTheme()`, `useRouter()`, etc. on every reactive update.
21
+ //
22
+ // `@pyreon/reactivity` is below `@pyreon/core` in the dep order, so it can't
23
+ // import `captureContextStack` / `restoreContextStack` directly. Core
24
+ // registers its capture+restore pair via `setSnapshotCapture` at module load.
25
+ // When unset (raw reactivity-only consumers), effects skip context handling
26
+ // — same behavior as before this hook existed.
27
+ export interface ReactiveSnapshotCapture {
28
+ capture: () => unknown
29
+ /** Run `fn` with the previously-captured snapshot active. */
30
+ restore: <T>(snap: unknown, fn: () => T) => T
31
+ }
32
+
33
+ let _snapshotCapture: ReactiveSnapshotCapture | null = null
34
+
35
+ /**
36
+ * Register a capture/restore pair so reactivity-layer effects (`_bind`,
37
+ * `renderEffect`, `effect`) can preserve external context (e.g. the core
38
+ * provide/useContext stack) across signal-driven re-runs. Called by
39
+ * `@pyreon/core`'s context module at import time. Idempotent — calling again
40
+ * replaces the previously registered hook.
41
+ */
42
+ export function setSnapshotCapture(hook: ReactiveSnapshotCapture | null): void {
43
+ _snapshotCapture = hook
44
+ }
45
+
14
46
  // ─── onCleanup ───────────────────────────────────────────────────────────────
15
47
  // Thread-local collector for cleanup functions registered via onCleanup()
16
48
  // during effect execution. Pushed/popped around the user callback in effect().
@@ -47,12 +79,45 @@ let _innerEffectCollector: Effect[] | null = null
47
79
 
48
80
  // Global error handler — called for unhandled errors thrown inside effects.
49
81
  // Defaults to console.error so silent failures are never swallowed.
50
- export let _errorHandler: (err: unknown) => void = (err) => {
82
+ //
83
+ // Two-layer model:
84
+ // 1. The user-overridable single handler set via `setErrorHandler` (legacy
85
+ // direct API).
86
+ // 2. A globalThis bridge `__pyreon_report_error__` that `@pyreon/core`
87
+ // installs in `registerErrorHandler` to forward effect errors into the
88
+ // same telemetry pipeline as component / mount / render errors.
89
+ // Pre-fix the two surfaces were disconnected — Sentry/Datadog wiring via
90
+ // core's `registerErrorHandler` silently missed effect-thrown errors.
91
+ // Globalthis-based to avoid an upward import (core depends on
92
+ // reactivity, not the reverse). Same shape as the perf-harness counter
93
+ // sink — zero cost when no consumer is installed.
94
+ //
95
+ // Both surfaces fire on every effect error. The legacy handler stays for
96
+ // backward compat; new consumers should prefer `@pyreon/core`'s
97
+ // `registerErrorHandler`.
98
+
99
+ interface PyreonErrorBridge {
100
+ __pyreon_report_error__?: (err: unknown, phase: 'effect') => void
101
+ }
102
+ const _errorBridge = globalThis as PyreonErrorBridge
103
+
104
+ function _defaultErrorHandler(err: unknown): void {
51
105
  console.error('[pyreon] Unhandled effect error:', err)
52
106
  }
53
107
 
108
+ let _userErrorHandler: ((err: unknown) => void) | undefined
109
+
110
+ export const _errorHandler: (err: unknown) => void = (err) => {
111
+ // 1. User-set or default direct handler.
112
+ ;(_userErrorHandler ?? _defaultErrorHandler)(err)
113
+ // 2. Global telemetry bridge (installed by @pyreon/core's
114
+ // registerErrorHandler). Forwards effect errors into reportError so
115
+ // Sentry/Datadog wiring captures them alongside component errors.
116
+ _errorBridge.__pyreon_report_error__?.(err, 'effect')
117
+ }
118
+
54
119
  export function setErrorHandler(fn: (err: unknown) => void): void {
55
- _errorHandler = fn
120
+ _userErrorHandler = fn
56
121
  }
57
122
 
58
123
  /** Remove an effect from all dependency subscriber sets (local deps array). */
@@ -67,9 +132,35 @@ function cleanupLocalDeps(deps: Set<() => void>[], fn: () => void): void {
67
132
  }
68
133
 
69
134
  export function effect(fn: () => (() => void) | void): Effect {
135
+ // Dev-mode warning for async effect callbacks (audit bug #1). The
136
+ // tracking context is the synchronous frame around `fn()`'s top half;
137
+ // anything after the first `await` runs detached, so signal reads on
138
+ // the back side aren't tracked and the effect won't re-run when those
139
+ // signals change. The fix at the call site is either to read all
140
+ // tracked signals BEFORE the first await, or split the work into two
141
+ // effects (or use `watch` for async-in-callback). Surfacing the warn
142
+ // at registration is the cheapest catch we can offer: an
143
+ // `AsyncFunction.prototype.constructor.name === 'AsyncFunction'`
144
+ // check is true at function-definition time without invoking anything.
145
+ if (process.env.NODE_ENV !== 'production') {
146
+ if (fn.constructor && fn.constructor.name === 'AsyncFunction') {
147
+ // oxlint-disable-next-line no-console
148
+ console.warn(
149
+ '[pyreon] effect() received an async function. Signal reads after the first `await` are NOT tracked — only the synchronous prefix is. ' +
150
+ 'Read every tracked signal BEFORE any await, or split into separate effects, or use `watch(source, asyncCb)` for async-in-callback patterns.',
151
+ )
152
+ }
153
+ }
154
+
70
155
  // Capture the scope at creation time — remains correct during future re-runs
71
156
  // even after setCurrentScope(null) has been called post-setup.
72
157
  const scope = getCurrentScope()
158
+ // Capture the external (core-context) snapshot at SETUP time. Reactive
159
+ // re-runs restore it before invoking fn, so provider lookups stay correct
160
+ // even when the global context stack has been destructively truncated by
161
+ // mountReactive's restoreContextStack cleanup. See `_bind` for the full
162
+ // rationale.
163
+ const snapshot = _snapshotCapture ? _snapshotCapture.capture() : null
73
164
  let disposed = false
74
165
  let isFirstRun = true
75
166
  let cleanup: (() => void) | undefined
@@ -115,7 +206,7 @@ export function effect(fn: () => (() => void) | void): Effect {
115
206
 
116
207
  const run = () => {
117
208
  if (disposed) return
118
- if ((import.meta as ViteMeta).env?.DEV === true)
209
+ if (process.env.NODE_ENV !== 'production')
119
210
  _countSink.__pyreon_count__?.('reactivity.effectRun')
120
211
  // Run previous cleanup before re-running
121
212
  runCleanup()
@@ -131,7 +222,16 @@ export function effect(fn: () => (() => void) | void): Effect {
131
222
  // Collect onCleanup() registrations during execution
132
223
  const collected: (() => void)[] = []
133
224
  _cleanupCollector = collected
134
- cleanup = withTracking(run, fn) || undefined
225
+ // First run executes inside the synchronous mount where the context
226
+ // stack is still intact — call fn directly to avoid pushing the
227
+ // captured snapshot a redundant second time. Subsequent re-runs
228
+ // happen AFTER mountReactive's cleanup has truncated the stack, so
229
+ // they need the snapshot restored to find provider frames.
230
+ const fnToRun =
231
+ isFirstRun || snapshot === null || _snapshotCapture === null
232
+ ? fn
233
+ : () => (_snapshotCapture as ReactiveSnapshotCapture).restore(snapshot, fn)
234
+ cleanup = withTracking(run, fnToRun) || undefined
135
235
  _cleanupCollector = null
136
236
  if (collected.length > 0) cleanups = collected
137
237
  setDepsCollector(null)
@@ -201,12 +301,26 @@ export function _bind(fn: () => void): () => void {
201
301
  const deps: Set<() => void>[] = []
202
302
  let disposed = false
203
303
 
304
+ // Capture external (core-context) snapshot at SETUP time. Re-runs restore
305
+ // it before invoking fn, so signal-driven re-runs see the same provider
306
+ // chain that was active when the binding was first set up — even if the
307
+ // global context stack has been destructively truncated by mountReactive's
308
+ // restoreContextStack cleanup in the meantime.
309
+ const snapshot = _snapshotCapture ? _snapshotCapture.capture() : null
310
+
204
311
  const run = () => {
205
312
  if (disposed) return
206
- fn()
313
+ if (snapshot !== null && _snapshotCapture) {
314
+ _snapshotCapture.restore(snapshot, fn)
315
+ } else {
316
+ fn()
317
+ }
207
318
  }
208
319
 
209
- // First run: track deps so we know what to unsubscribe on dispose
320
+ // First run: track deps so we know what to unsubscribe on dispose. We
321
+ // intentionally call `fn` directly (not `run`) here — the synchronous
322
+ // mount stack is already intact at this point, so restoring the captured
323
+ // snapshot would just push the same frames again redundantly.
210
324
  setDepsCollector(deps)
211
325
  withTracking(run, fn)
212
326
  setDepsCollector(null)
@@ -244,28 +358,49 @@ function renderEffectFullTrack(deps: Set<() => void>[], run: () => void, fn: ()
244
358
  }
245
359
 
246
360
  export function renderEffect(fn: () => void): () => void {
361
+ // Same dev warning as `effect()` — signal reads after the first
362
+ // await aren't tracked. See effect()'s docstring for full reasoning.
363
+ if (process.env.NODE_ENV !== 'production') {
364
+ if (fn.constructor && fn.constructor.name === 'AsyncFunction') {
365
+ // oxlint-disable-next-line no-console
366
+ console.warn(
367
+ '[pyreon] renderEffect() received an async function. Signal reads after the first `await` are NOT tracked — only the synchronous prefix is. ' +
368
+ 'Read every tracked signal BEFORE any await, or split into separate effects, or use `watch(source, asyncCb)` for async-in-callback patterns.',
369
+ )
370
+ }
371
+ }
372
+
247
373
  const deps: Set<() => void>[] = []
248
374
  let disposed = false
249
375
  let isFirstRun = true
250
376
 
377
+ // Same rationale as `_bind`: capture the external context snapshot at
378
+ // SETUP and restore it on signal-driven re-runs so provider lookups stay
379
+ // correct even after `mountReactive`'s cleanup truncates the global stack.
380
+ const snapshot = _snapshotCapture ? _snapshotCapture.capture() : null
381
+
382
+ const trackedFn =
383
+ snapshot !== null && _snapshotCapture
384
+ ? () => (_snapshotCapture as ReactiveSnapshotCapture).restore(snapshot, fn)
385
+ : fn
386
+
251
387
  const run = () => {
252
388
  if (disposed) return
253
- // After first run, if deps haven't changed structure, we can skip
254
- // the full cleanup+retrack path. However, renderEffect deps CAN
255
- // change (unlike _bind), so we always do the full track.
256
- // Optimization: skip cleanup on first run (deps are empty).
257
389
  if (isFirstRun) {
258
390
  isFirstRun = false
259
391
  setDepsCollector(deps)
260
392
  _setActiveEffect(run)
261
393
  try {
394
+ // First run: stack is still intact (we're inside the synchronous
395
+ // mount), so call fn directly to avoid pushing the snapshot frames
396
+ // a second time.
262
397
  fn()
263
398
  } finally {
264
399
  _restoreActiveEffect()
265
400
  setDepsCollector(null)
266
401
  }
267
402
  } else {
268
- renderEffectFullTrack(deps, run, fn)
403
+ renderEffectFullTrack(deps, run, trackedFn)
269
404
  }
270
405
  }
271
406
 
package/src/env.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Minimal process type — just enough for `process.env.NODE_ENV` checks.
3
+ * Avoids requiring @types/node in consumers that import pyreon source
4
+ * via the `"bun"` export condition.
5
+ */
6
+ declare var process: { env: { NODE_ENV?: string } }
package/src/index.ts CHANGED
@@ -5,7 +5,16 @@ export { Cell, cell } from './cell'
5
5
  export { type Computed, type ComputedOptions, computed } from './computed'
6
6
  export { createSelector } from './createSelector'
7
7
  export { inspectSignal, onSignalUpdate, why } from './debug'
8
- export { _bind, type Effect, effect, onCleanup, renderEffect, setErrorHandler } from './effect'
8
+ export {
9
+ _bind,
10
+ type Effect,
11
+ effect,
12
+ onCleanup,
13
+ type ReactiveSnapshotCapture,
14
+ renderEffect,
15
+ setErrorHandler,
16
+ setSnapshotCapture,
17
+ } from './effect'
9
18
  export { reconcile } from './reconcile'
10
19
  export { createResource, type Resource } from './resource'
11
20
  export { EffectScope, effectScope, getCurrentScope, setCurrentScope } from './scope'
package/src/signal.ts CHANGED
@@ -1,15 +1,8 @@
1
- declare const process: { env: { NODE_ENV?: string } } | undefined
2
-
3
- const __DEV__ = typeof process !== 'undefined' && process?.env?.NODE_ENV !== 'production'
4
-
5
1
  import { batch, enqueuePendingNotification, isBatching } from './batch'
6
2
  import { _notifyTraceListeners, isTracing } from './debug'
7
3
  import { notifySubscribers, trackSubscriber } from './tracking'
8
4
 
9
5
  // Dev-time counter sink — see packages/internals/perf-harness for contract.
10
- interface ViteMeta {
11
- readonly env?: { readonly DEV?: boolean }
12
- }
13
6
  const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
14
7
 
15
8
  export interface SignalDebugInfo<T> {
@@ -86,7 +79,7 @@ function _peek(this: SignalFn<unknown>) {
86
79
 
87
80
  function _set(this: SignalFn<unknown>, newValue: unknown) {
88
81
  if (Object.is(this._v, newValue)) return
89
- if ((import.meta as ViteMeta).env?.DEV === true)
82
+ if (process.env.NODE_ENV !== 'production')
90
83
  _countSink.__pyreon_count__?.('reactivity.signalWrite')
91
84
  const prev = this._v
92
85
  this._v = newValue
@@ -173,12 +166,12 @@ function _debug(this: SignalFn<unknown>): SignalDebugInfo<unknown> {
173
166
  * update, subscribe) are shared across all signals — not per-signal closures.
174
167
  */
175
168
  export function signal<T>(initialValue: T, options?: SignalOptions): Signal<T> {
176
- if ((import.meta as ViteMeta).env?.DEV === true)
169
+ if (process.env.NODE_ENV !== 'production')
177
170
  _countSink.__pyreon_count__?.('reactivity.signalCreate')
178
171
  // The read function is the only per-signal closure.
179
172
  // It doubles as the SubscriberHost (_s property) for trackSubscriber.
180
173
  const read = ((...args: unknown[]) => {
181
- if (__DEV__ && args.length > 0) {
174
+ if (process.env.NODE_ENV !== 'production' && args.length > 0) {
182
175
  // oxlint-disable-next-line no-console
183
176
  console.warn(
184
177
  '[Pyreon] signal() was called with an argument. ' +