@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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +87 -24
- package/lib/types/index.d.ts +14 -1
- package/package.json +5 -4
- package/src/batch.ts +135 -32
- package/src/computed.ts +21 -6
- package/src/effect.ts +149 -14
- package/src/env.d.ts +6 -0
- package/src/index.ts +10 -1
- package/src/signal.ts +3 -10
- 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/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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
403
|
+
renderEffectFullTrack(deps, run, trackedFn)
|
|
269
404
|
}
|
|
270
405
|
}
|
|
271
406
|
|
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/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 (
|
|
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 (
|
|
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 (
|
|
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. ' +
|