@pyreon/reactivity 0.13.1 → 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/batch.ts CHANGED
@@ -1,15 +1,66 @@
1
1
  // Batch multiple signal updates into a single notification pass.
2
- // Uses a Set so the same subscriber is never flushed more than once per batch,
3
- // even if multiple signals it depends on change within the same batch.
2
+ // Two-tier flush: computed recomputes drain first (within-pass Set-dedup),
3
+ // THEN effects drain (multi-pass with cross-pass re-fire support).
4
+ //
5
+ // Dev-mode invariant gate: see https://github.com/pyreon/pyreon/blob/main/packages/core/reactivity/src/tests/batch.test.ts
6
+ // for the property-based test that fuzzes random cascade graphs against this
7
+ // invariant. The build-time gate folds to dead code in production bundles.
8
+ const __DEV__ = process.env.NODE_ENV !== 'production'
4
9
 
5
10
  let batchDepth = 0
6
11
 
7
- // Two pre-allocated Sets swapped on each flush — avoids allocating a new Set()
8
- // on every batch exit. The "active" set collects enqueued notifications; on flush
9
- // we swap to the other set and iterate the captured one, then clear it for reuse.
10
- const setA = new Set<() => void>()
11
- const setB = new Set<() => void>()
12
- let pendingNotifications = setA
12
+ // Two-tier queue design:
13
+ //
14
+ // 1. **`pendingRecomputes`** computed.recompute callbacks. Drained FIRST in
15
+ // a cascading-iteration loop: cascading recomputes enqueued during a
16
+ // drain land in the same Set (Set.add idempotency dedupes), iteration
17
+ // visits added entries (JS Set iteration semantics), and the recompute
18
+ // layer settles before any effect fires. This guarantees effects always
19
+ // read fully-propagated computed values.
20
+ //
21
+ // 2. **`pendingEffects`** — effect.run callbacks. Drained SECOND, multi-pass.
22
+ // Within a single pass, Set.add idempotency gives us within-pass dedup
23
+ // (diamond / multi-dep selector). Across passes (entries re-enqueued
24
+ // AFTER being visited in the current pass — see `_visitedThisPass`), they
25
+ // fire AGAIN — needed for control flow that re-renders based on its own
26
+ // dispatch (e.g. ErrorBoundary's handler calling `error.set(err)` during
27
+ // the same run that mounted the throwing child). MAX_PASSES caps total
28
+ // passes at 32 to prevent pathological infinite re-enqueue loops.
29
+ //
30
+ // **Why two tiers, not one Set:**
31
+ // - Single-Set iteration (the prior design) worked for shallow cascades
32
+ // because the cascading recompute → effect re-enqueue happened BEFORE
33
+ // iteration reached the effect. For deep cascades (3+ hops), iteration
34
+ // reached the effect (with its stale upstream value) BEFORE the cascade
35
+ // finished propagating. Effect read stale values; subsequent cascade
36
+ // re-enqueues were dropped by Set.add idempotency.
37
+ // - Splitting recomputes from effects fixes this: all computed recomputes
38
+ // settle before any effect runs. The cascade-asymmetry contract from
39
+ // PR #381 is preserved (effects still fire once per batched change),
40
+ // AND deep-cascade correctness is added (effects always read settled
41
+ // values).
42
+ // - Multi-pass effect drain unblocks ErrorBoundary's "re-fire after
43
+ // dispatching from inside own run" pattern without breaking the
44
+ // single-fire contract for non-self-dispatching effects.
45
+ //
46
+ // **How a callback gets routed:** computed registers its `recompute` via
47
+ // `_markRecompute(fn)` at creation time. The internal `_recomputes` WeakSet
48
+ // tracks them. `enqueuePendingNotification` checks the WeakSet to route.
49
+ const pendingRecomputes = new Set<() => void>()
50
+ const pendingEffects = new Set<() => void>()
51
+ const _nextEffectPass = new Set<() => void>()
52
+ let _visitedThisPass: Set<() => void> | null = null
53
+ const _recomputes = new WeakSet<() => void>()
54
+ const MAX_PASSES = 32
55
+
56
+ /**
57
+ * Mark a callback as a computed recompute (called from computed.ts at
58
+ * creation time). Routes future enqueues into the recompute queue so they
59
+ * settle before any effects fire.
60
+ */
61
+ export function _markRecompute(fn: () => void): void {
62
+ _recomputes.add(fn)
63
+ }
13
64
 
14
65
  export function batch(fn: () => void): void {
15
66
  batchDepth++
@@ -17,14 +68,74 @@ export function batch(fn: () => void): void {
17
68
  fn()
18
69
  } finally {
19
70
  batchDepth--
20
- if (batchDepth === 0 && pendingNotifications.size > 0) {
21
- // Swap to the other pre-allocated Set before flushing so new enqueues
22
- // during notification land in the alternate Set, not mixed into the
23
- // current iteration.
24
- const flush = pendingNotifications
25
- pendingNotifications = flush === setA ? setB : setA
26
- for (const notify of flush) notify()
27
- flush.clear()
71
+ if (batchDepth === 0 && (pendingRecomputes.size > 0 || pendingEffects.size > 0)) {
72
+ // Keep batching active during flush so cascade-notifications emitted
73
+ // by flushing subscribers enqueue into the same queues (dedup against
74
+ // already-queued entries) instead of firing inline.
75
+ batchDepth = 1
76
+ try {
77
+ // Outer loop: alternate between tier-1 (recomputes) and tier-2
78
+ // (effects) until both queues are empty. An effect can write a
79
+ // signal whose subscribers include lazy `computed.recompute`s — those
80
+ // get enqueued into pendingRecomputes mid-effect, and we need to
81
+ // drain them BEFORE the next effect pass so downstream effects see
82
+ // the propagated dirty flag. MAX_PASSES caps the OUTER loop —
83
+ // counts effect-tier passes only since recomputes converge by
84
+ // `equals` short-circuit and don't infinite-loop in practice.
85
+ let effectPass = 0
86
+ while (pendingRecomputes.size > 0 || pendingEffects.size > 0) {
87
+ // Tier 1: drain all recomputes via cascading iteration. Set
88
+ // semantics visit entries added during iteration; Set.add
89
+ // idempotency dedupes diamond cascades. Recomputes converge by
90
+ // `equals` short-circuit (computedWithEquals returns early when
91
+ // value is unchanged) and computedLazy's `if (dirty) return`
92
+ // guard prevents re-fire.
93
+ for (const r of pendingRecomputes) r()
94
+ pendingRecomputes.clear()
95
+
96
+ // Tier 2: drain ONE pass of effects in multi-pass mode. Within-
97
+ // pass dedup preserved by Set.add idempotency on entries not yet
98
+ // visited this pass. Cross-pass re-fire enabled by routing
99
+ // already-visited entries to `_nextEffectPass` (handled in
100
+ // `enqueuePendingNotification`). After the pass, loop back to
101
+ // tier 1 to drain any recomputes the effects enqueued.
102
+ if (pendingEffects.size > 0) {
103
+ if (++effectPass > MAX_PASSES) {
104
+ if (__DEV__) {
105
+ // oxlint-disable-next-line no-console
106
+ console.warn(
107
+ '[pyreon] batch effect flush exceeded MAX_PASSES (32) — possible infinite re-enqueue loop. ' +
108
+ `${pendingEffects.size} pending effects dropped. ` +
109
+ 'See packages/core/reactivity/src/batch.ts for the multi-pass flush contract.',
110
+ )
111
+ }
112
+ break
113
+ }
114
+ _visitedThisPass = new Set<() => void>()
115
+ for (const notify of pendingEffects) {
116
+ _visitedThisPass.add(notify)
117
+ notify()
118
+ }
119
+ // Promote next-pass entries to pending for the next iteration.
120
+ pendingEffects.clear()
121
+ for (const next of _nextEffectPass) pendingEffects.add(next)
122
+ _nextEffectPass.clear()
123
+ }
124
+ }
125
+ } finally {
126
+ // Clear ALWAYS — even if a notify threw mid-iteration. Without this,
127
+ // the unflushed remainder leaks into the next batch and refires
128
+ // (audit bug #19). Effects wrap their callbacks in try/catch
129
+ // internally so this is rarely reachable in practice, but raw
130
+ // signal subscribers (signal.subscribe) and lower-level consumers
131
+ // can throw straight through, and a future refactor that swallows
132
+ // less aggressively would silently regress without this guard.
133
+ pendingRecomputes.clear()
134
+ pendingEffects.clear()
135
+ _nextEffectPass.clear()
136
+ _visitedThisPass = null
137
+ batchDepth = 0
138
+ }
28
139
  }
29
140
  }
30
141
  }
@@ -33,8 +144,22 @@ export function isBatching(): boolean {
33
144
  return batchDepth > 0
34
145
  }
35
146
 
147
+ export function enquePendingNotificationDeprecated(): void {
148
+ // Kept as a comment placeholder — actual export is below. (Empty body to
149
+ // keep this file's exports list stable across the refactor.)
150
+ }
151
+
36
152
  export function enqueuePendingNotification(notify: () => void): void {
37
- pendingNotifications.add(notify)
153
+ // Route based on callback kind. Computed recomputes go to tier-1 queue,
154
+ // effects to tier-2. Within tier 2, already-visited-this-pass entries
155
+ // route to next-pass for cross-pass re-fire (ErrorBoundary's pattern).
156
+ if (_recomputes.has(notify)) {
157
+ pendingRecomputes.add(notify)
158
+ } else if (_visitedThisPass !== null && _visitedThisPass.has(notify)) {
159
+ _nextEffectPass.add(notify)
160
+ } else {
161
+ pendingEffects.add(notify)
162
+ }
38
163
  }
39
164
 
40
165
  /**
package/src/computed.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { _markRecompute } from './batch'
1
2
  import { _errorHandler } from './effect'
2
3
  import { getCurrentScope } from './scope'
3
4
  import {
@@ -9,6 +10,9 @@ import {
9
10
  withTracking,
10
11
  } from './tracking'
11
12
 
13
+ // Dev-time counter sink — see packages/internals/perf-harness for contract.
14
+ const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
15
+
12
16
  export interface Computed<T> {
13
17
  (): T
14
18
  /** Remove this computed from all its reactive dependencies. */
@@ -48,6 +52,21 @@ function trackWithLocalDeps<T>(deps: Set<() => void>[], effect: () => void, fn:
48
52
  }
49
53
 
50
54
  export function computed<T>(fn: () => T, options?: ComputedOptions<T>): Computed<T> {
55
+ // Dev warning for async computed callbacks (audit bug #1 — extension).
56
+ // `computed(async () => …)` returns `Computed<Promise<T>>`, which silently
57
+ // breaks every consumer that expects `Computed<T>`. There's no scenario
58
+ // where async makes sense here — the recompute fires synchronously and
59
+ // tracks signals only in the synchronous prefix. For async-derived
60
+ // state, use `createResource` or a `signal<T>` updated from an effect.
61
+ if (process.env.NODE_ENV !== 'production') {
62
+ if (fn.constructor && fn.constructor.name === 'AsyncFunction') {
63
+ // oxlint-disable-next-line no-console
64
+ console.warn(
65
+ '[pyreon] computed() received an async function. The result type becomes `Computed<Promise<T>>`, and signal reads after the first `await` are NOT tracked. ' +
66
+ 'Use `createResource` for async-derived state, or compute synchronously over a signal that holds the awaited value.',
67
+ )
68
+ }
69
+ }
51
70
  return options?.equals ? computedWithEquals(fn, options.equals) : computedLazy(fn)
52
71
  }
53
72
 
@@ -76,12 +95,19 @@ function computedLazy<T>(fn: () => T): Computed<T> {
76
95
  if (host._s) notifySubscribers(host._s)
77
96
  if (directFns) for (const f of directFns) f?.()
78
97
  }
98
+ _markRecompute(recompute)
79
99
 
80
100
  const read = (): T => {
81
101
  trackSubscriber(host)
82
102
  if (dirty) {
103
+ if (process.env.NODE_ENV !== 'production')
104
+ _countSink.__pyreon_count__?.('reactivity.computedRecompute')
83
105
  try {
84
106
  if (tracked) {
107
+ // Deps already established from first run — skip adding to
108
+ // subscriber Sets again (they already contain recompute).
109
+ // Still need withTracking so activeEffect is set correctly
110
+ // for any NEW signals read on this evaluation.
85
111
  setSkipDepsCollection(true)
86
112
  value = withTracking(recompute, fn)
87
113
  setSkipDepsCollection(false)
@@ -90,7 +116,6 @@ function computedLazy<T>(fn: () => T): Computed<T> {
90
116
  tracked = true
91
117
  }
92
118
  } catch (err) {
93
- setSkipDepsCollection(false)
94
119
  _errorHandler(err)
95
120
  }
96
121
  dirty = false
@@ -142,6 +167,8 @@ function computedWithEquals<T>(fn: () => T, equals: (prev: T, next: T) => boolea
142
167
 
143
168
  const recompute = () => {
144
169
  if (disposed) return
170
+ if (process.env.NODE_ENV !== 'production')
171
+ _countSink.__pyreon_count__?.('reactivity.computedRecompute')
145
172
  cleanupLocalDeps(deps, recompute)
146
173
  try {
147
174
  const next = trackWithLocalDeps(deps, recompute, fn)
@@ -156,10 +183,13 @@ function computedWithEquals<T>(fn: () => T, equals: (prev: T, next: T) => boolea
156
183
  if (host._s) notifySubscribers(host._s)
157
184
  if (directFns) for (const f of directFns) f?.()
158
185
  }
186
+ _markRecompute(recompute)
159
187
 
160
188
  const read = (): T => {
161
189
  trackSubscriber(host)
162
190
  if (dirty) {
191
+ if (process.env.NODE_ENV !== 'production')
192
+ _countSink.__pyreon_count__?.('reactivity.computedRecompute')
163
193
  cleanupLocalDeps(deps, recompute)
164
194
  try {
165
195
  value = trackWithLocalDeps(deps, recompute, fn)
package/src/effect.ts CHANGED
@@ -1,10 +1,48 @@
1
1
  import { getCurrentScope } from './scope'
2
2
  import { _restoreActiveEffect, _setActiveEffect, setDepsCollector, withTracking } from './tracking'
3
3
 
4
+ // Dev-time counter sink — see packages/internals/perf-harness for contract.
5
+ const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
6
+
4
7
  export interface Effect {
5
8
  dispose(): void
6
9
  }
7
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
+
8
46
  // ─── onCleanup ───────────────────────────────────────────────────────────────
9
47
  // Thread-local collector for cleanup functions registered via onCleanup()
10
48
  // during effect execution. Pushed/popped around the user callback in effect().
@@ -33,14 +71,53 @@ export function onCleanup(fn: () => void): void {
33
71
  }
34
72
  }
35
73
 
74
+ // Thread-local collector for nested effects — captures effect() calls made
75
+ // inside another effect's fn() body so the parent can dispose them on
76
+ // re-run / disposal. Without this, inner effects leak across outer
77
+ // lifecycle boundaries (caught by cleanup-nested.test.ts).
78
+ let _innerEffectCollector: Effect[] | null = null
79
+
36
80
  // Global error handler — called for unhandled errors thrown inside effects.
37
81
  // Defaults to console.error so silent failures are never swallowed.
38
- 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 {
39
105
  console.error('[pyreon] Unhandled effect error:', err)
40
106
  }
41
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
+
42
119
  export function setErrorHandler(fn: (err: unknown) => void): void {
43
- _errorHandler = fn
120
+ _userErrorHandler = fn
44
121
  }
45
122
 
46
123
  /** Remove an effect from all dependency subscriber sets (local deps array). */
@@ -55,9 +132,35 @@ function cleanupLocalDeps(deps: Set<() => void>[], fn: () => void): void {
55
132
  }
56
133
 
57
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
+
58
155
  // Capture the scope at creation time — remains correct during future re-runs
59
156
  // even after setCurrentScope(null) has been called post-setup.
60
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
61
164
  let disposed = false
62
165
  let isFirstRun = true
63
166
  let cleanup: (() => void) | undefined
@@ -65,8 +168,22 @@ export function effect(fn: () => (() => void) | void): Effect {
65
168
  const deps: Set<() => void>[] = []
66
169
 
67
170
  let cleanups: (() => void)[] | undefined
171
+ // Inner effects created during this effect's fn() body. Disposed on
172
+ // outer re-run (before the next fn()) and on outer dispose(). Without
173
+ // this, nested effects leak across outer lifecycle boundaries.
174
+ let innerEffects: Effect[] | null = null
68
175
 
69
176
  const runCleanup = () => {
177
+ if (innerEffects) {
178
+ for (const ie of innerEffects) {
179
+ try {
180
+ ie.dispose()
181
+ } catch (err) {
182
+ _errorHandler(err)
183
+ }
184
+ }
185
+ innerEffects = null
186
+ }
70
187
  if (cleanups) {
71
188
  for (const c of cleanups) {
72
189
  try {
@@ -89,15 +206,32 @@ export function effect(fn: () => (() => void) | void): Effect {
89
206
 
90
207
  const run = () => {
91
208
  if (disposed) return
209
+ if (process.env.NODE_ENV !== 'production')
210
+ _countSink.__pyreon_count__?.('reactivity.effectRun')
92
211
  // Run previous cleanup before re-running
93
212
  runCleanup()
213
+ // Start a new inner-effect collection window. Effects created during
214
+ // fn() will push themselves into this array and be disposed on the
215
+ // next re-run or on dispose.
216
+ const outerCollector = _innerEffectCollector
217
+ const myInners: Effect[] = []
218
+ _innerEffectCollector = myInners
94
219
  try {
95
220
  cleanupLocalDeps(deps, run)
96
221
  setDepsCollector(deps)
97
222
  // Collect onCleanup() registrations during execution
98
223
  const collected: (() => void)[] = []
99
224
  _cleanupCollector = collected
100
- 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
101
235
  _cleanupCollector = null
102
236
  if (collected.length > 0) cleanups = collected
103
237
  setDepsCollector(null)
@@ -105,7 +239,10 @@ export function effect(fn: () => (() => void) | void): Effect {
105
239
  _cleanupCollector = null
106
240
  setDepsCollector(null)
107
241
  _errorHandler(err)
242
+ } finally {
243
+ _innerEffectCollector = outerCollector
108
244
  }
245
+ if (myInners.length > 0) innerEffects = myInners
109
246
  // Notify scope after each reactive re-run (not the initial synchronous run)
110
247
  // so onUpdate hooks fire after the DOM has settled.
111
248
  if (!isFirstRun) scope?.notifyEffectRan()
@@ -122,8 +259,14 @@ export function effect(fn: () => (() => void) | void): Effect {
122
259
  },
123
260
  }
124
261
 
125
- // Auto-register with the active EffectScope (if any)
126
- getCurrentScope()?.add(e)
262
+ // If we're inside another effect's run, register with it so the outer
263
+ // disposes this inner automatically.
264
+ if (_innerEffectCollector !== null) {
265
+ _innerEffectCollector.push(e)
266
+ } else {
267
+ // Otherwise auto-register with the active EffectScope (if any)
268
+ getCurrentScope()?.add(e)
269
+ }
127
270
 
128
271
  return e
129
272
  }
@@ -158,12 +301,26 @@ export function _bind(fn: () => void): () => void {
158
301
  const deps: Set<() => void>[] = []
159
302
  let disposed = false
160
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
+
161
311
  const run = () => {
162
312
  if (disposed) return
163
- fn()
313
+ if (snapshot !== null && _snapshotCapture) {
314
+ _snapshotCapture.restore(snapshot, fn)
315
+ } else {
316
+ fn()
317
+ }
164
318
  }
165
319
 
166
- // 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.
167
324
  setDepsCollector(deps)
168
325
  withTracking(run, fn)
169
326
  setDepsCollector(null)
@@ -201,12 +358,50 @@ function renderEffectFullTrack(deps: Set<() => void>[], run: () => void, fn: ()
201
358
  }
202
359
 
203
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
+
204
373
  const deps: Set<() => void>[] = []
205
374
  let disposed = false
375
+ let isFirstRun = true
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
206
386
 
207
387
  const run = () => {
208
388
  if (disposed) return
209
- renderEffectFullTrack(deps, run, fn)
389
+ if (isFirstRun) {
390
+ isFirstRun = false
391
+ setDepsCollector(deps)
392
+ _setActiveEffect(run)
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.
397
+ fn()
398
+ } finally {
399
+ _restoreActiveEffect()
400
+ setDepsCollector(null)
401
+ }
402
+ } else {
403
+ renderEffectFullTrack(deps, run, trackedFn)
404
+ }
210
405
  }
211
406
 
212
407
  run()
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/scope.ts CHANGED
@@ -2,14 +2,16 @@
2
2
  // and disposes them all at once when the component unmounts.
3
3
 
4
4
  export class EffectScope {
5
- private _effects: { dispose(): void }[] = []
5
+ private _effects: { dispose(): void }[] | null = null
6
6
  private _active = true
7
- private _updateHooks: (() => void)[] = []
7
+ private _updateHooks: (() => void)[] | null = null
8
8
  private _updatePending = false
9
9
 
10
10
  /** Register an effect/computed to be disposed when this scope stops. */
11
11
  add(e: { dispose(): void }): void {
12
- if (this._active) this._effects.push(e)
12
+ if (!this._active) return
13
+ if (this._effects === null) this._effects = []
14
+ this._effects.push(e)
13
15
  }
14
16
 
15
17
  /**
@@ -30,6 +32,7 @@ export class EffectScope {
30
32
 
31
33
  /** Register a callback to run after any reactive update in this scope. */
32
34
  addUpdateHook(fn: () => void): void {
35
+ if (this._updateHooks === null) this._updateHooks = []
33
36
  this._updateHooks.push(fn)
34
37
  }
35
38
 
@@ -38,11 +41,11 @@ export class EffectScope {
38
41
  * Schedules onUpdate hooks via microtask so all synchronous effects settle first.
39
42
  */
40
43
  notifyEffectRan(): void {
41
- if (!this._active || this._updateHooks.length === 0 || this._updatePending) return
44
+ if (!this._active || !this._updateHooks || this._updateHooks.length === 0 || this._updatePending) return
42
45
  this._updatePending = true
43
46
  queueMicrotask(() => {
44
47
  this._updatePending = false
45
- if (!this._active) return
48
+ if (!this._active || !this._updateHooks) return
46
49
  for (const fn of this._updateHooks) {
47
50
  try {
48
51
  fn()
@@ -56,9 +59,11 @@ export class EffectScope {
56
59
  /** Dispose all tracked effects. */
57
60
  stop(): void {
58
61
  if (!this._active) return
59
- for (const e of this._effects) e.dispose()
60
- this._effects = []
61
- this._updateHooks = []
62
+ if (this._effects) {
63
+ for (const e of this._effects) e.dispose()
64
+ }
65
+ this._effects = null
66
+ this._updateHooks = null
62
67
  this._updatePending = false
63
68
  this._active = false
64
69
  }