@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/README.md +1 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +143 -29
- package/lib/types/index.d.ts +14 -1
- package/package.json +5 -4
- package/src/batch.ts +142 -17
- package/src/computed.ts +31 -1
- package/src/effect.ts +203 -8
- package/src/env.d.ts +6 -0
- package/src/index.ts +10 -1
- package/src/scope.ts +13 -8
- package/src/signal.ts +31 -9
- package/src/tests/batch.test.ts +418 -0
- package/src/tests/computed.test.ts +32 -0
- package/src/tests/effect.test.ts +65 -0
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
package/src/batch.ts
CHANGED
|
@@ -1,15 +1,66 @@
|
|
|
1
1
|
// Batch multiple signal updates into a single notification pass.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 &&
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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 {
|
|
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)
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
}
|