@pyreon/reactivity 0.18.0 → 0.20.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 +319 -30
- package/lib/types/index.d.ts +127 -3
- package/package.json +1 -1
- package/src/computed.ts +57 -16
- package/src/effect.ts +15 -1
- package/src/index.ts +16 -0
- package/src/manifest.ts +68 -1
- package/src/reactive-devtools.ts +281 -0
- package/src/reactive-trace.ts +142 -0
- package/src/reconcile.ts +12 -0
- package/src/signal.ts +40 -21
- package/src/store.ts +11 -0
- package/src/tests/computed.test.ts +31 -0
- package/src/tests/coverage-hardening.test.ts +471 -0
- package/src/tests/manifest-snapshot.test.ts +5 -3
- package/src/tests/reactive-devtools-treeshake.test.ts +48 -0
- package/src/tests/reactive-devtools.test.ts +296 -0
- package/src/tests/reactive-trace.test.ts +102 -0
- package/src/tests/reconcile-security.test.ts +45 -0
- package/src/tests/signal.test.ts +35 -9
package/lib/types/index.d.ts
CHANGED
|
@@ -133,9 +133,9 @@ interface Signal<T> {
|
|
|
133
133
|
subscribe(listener: () => void): () => void;
|
|
134
134
|
/**
|
|
135
135
|
* Register a direct updater — even lighter than subscribe().
|
|
136
|
-
* Uses a flat array instead of Set. Disposal nulls the slot (no Set.delete).
|
|
137
136
|
* Intended for compiler-emitted DOM bindings (_bindText, _bindDirect).
|
|
138
|
-
* Returns a disposer that
|
|
137
|
+
* Returns a disposer that removes the updater (O(1)); the live set
|
|
138
|
+
* stays bounded under register/dispose churn.
|
|
139
139
|
*/
|
|
140
140
|
direct(updater: () => void): () => void;
|
|
141
141
|
/**
|
|
@@ -209,6 +209,130 @@ declare function why(): void;
|
|
|
209
209
|
*/
|
|
210
210
|
declare function inspectSignal<T>(sig: Signal<T>): SignalDebugInfo<T>;
|
|
211
211
|
//#endregion
|
|
212
|
+
//#region src/reactive-devtools.d.ts
|
|
213
|
+
/**
|
|
214
|
+
* Reactive devtools bridge — an OPT-IN, leak-free introspection layer
|
|
215
|
+
* over the live signal / computed / effect graph.
|
|
216
|
+
*
|
|
217
|
+
* Powers the `@pyreon/devtools` Signals / Graph / Effects / Console
|
|
218
|
+
* surfaces. Design constraints (mirroring `reactive-trace.ts`):
|
|
219
|
+
*
|
|
220
|
+
* - **Zero cost until attached.** Every instrumentation entry point
|
|
221
|
+
* early-returns on `!_active`. The registry is empty and no work
|
|
222
|
+
* happens until a devtools client calls `activateReactiveDevtools()`.
|
|
223
|
+
* The single call site per creation/track sits inside the existing
|
|
224
|
+
* `process.env.NODE_ENV !== 'production'` gate (tree-shaken in prod)
|
|
225
|
+
* and is structurally identical to the perf-harness counter calls
|
|
226
|
+
* and `_recordSignalWrite` already on those paths.
|
|
227
|
+
* - **No retention / no leak.** Nodes are held via `WeakRef` and
|
|
228
|
+
* pruned by a `FinalizationRegistry`. The registry never pins a
|
|
229
|
+
* signal/computed/effect alive. Edges + the fire ring buffer hold
|
|
230
|
+
* only numeric ids and primitives, never node references or values.
|
|
231
|
+
* - **Snapshot on demand.** `getReactiveGraph()` recomputes the edge
|
|
232
|
+
* set fresh from the live subscriber Sets — no incremental
|
|
233
|
+
* bookkeeping to drift out of sync with `cleanupEffect`.
|
|
234
|
+
*
|
|
235
|
+
* Names: signals carry `.label` (set explicitly or by the vite plugin's
|
|
236
|
+
* dev auto-naming). Computeds/effects have no name in the framework, so
|
|
237
|
+
* they get a stable synthetic label (`derived#12` / `effect#7`).
|
|
238
|
+
*/
|
|
239
|
+
type ReactiveNodeKind = 'signal' | 'derived' | 'effect';
|
|
240
|
+
interface ReactiveNode {
|
|
241
|
+
id: number;
|
|
242
|
+
kind: ReactiveNodeKind;
|
|
243
|
+
/** Explicit `.label` for signals; synthetic (`derived#id`) otherwise. */
|
|
244
|
+
name: string;
|
|
245
|
+
/** Bounded string preview of the current value (signals/derived only). */
|
|
246
|
+
value: string;
|
|
247
|
+
/** Live downstream subscriber count. */
|
|
248
|
+
subscribers: number;
|
|
249
|
+
/** Total times this node has fired/recomputed since activation. */
|
|
250
|
+
fires: number;
|
|
251
|
+
/** `performance.now()` of the most recent fire, or null. */
|
|
252
|
+
lastFire: number | null;
|
|
253
|
+
}
|
|
254
|
+
interface ReactiveEdge {
|
|
255
|
+
/** Source node id (the reactive value being read). */
|
|
256
|
+
from: number;
|
|
257
|
+
/** Subscriber node id (the computed/effect that read it). */
|
|
258
|
+
to: number;
|
|
259
|
+
}
|
|
260
|
+
interface ReactiveGraph {
|
|
261
|
+
nodes: ReactiveNode[];
|
|
262
|
+
edges: ReactiveEdge[];
|
|
263
|
+
}
|
|
264
|
+
interface ReactiveFire {
|
|
265
|
+
id: number;
|
|
266
|
+
/** `performance.now()` at fire time. */
|
|
267
|
+
ts: number;
|
|
268
|
+
}
|
|
269
|
+
/** Activate the bridge. Idempotent. Called when a devtools client attaches. */
|
|
270
|
+
declare function activateReactiveDevtools(): void;
|
|
271
|
+
/**
|
|
272
|
+
* Deactivate + drop all retained state. Called when the devtools client
|
|
273
|
+
* disconnects so a closed panel leaves zero residue.
|
|
274
|
+
*/
|
|
275
|
+
declare function deactivateReactiveDevtools(): void;
|
|
276
|
+
declare function isReactiveDevtoolsActive(): boolean;
|
|
277
|
+
/**
|
|
278
|
+
* Fresh snapshot of the live reactive graph. Edges are recomputed from
|
|
279
|
+
* each live node's current subscriber Set — always consistent with the
|
|
280
|
+
* framework's real subscription state, no incremental drift.
|
|
281
|
+
*/
|
|
282
|
+
declare function getReactiveGraph(): ReactiveGraph;
|
|
283
|
+
/** Bounded recent-fire timeline (oldest → newest). Fresh copy. */
|
|
284
|
+
declare function getReactiveFires(): ReactiveFire[];
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region src/reactive-trace.d.ts
|
|
287
|
+
/**
|
|
288
|
+
* Reactive trace — a bounded, dev-only ring buffer of recent signal
|
|
289
|
+
* writes. When a signal-based UI throws, the single most useful
|
|
290
|
+
* debugging question is "what reactive state changed in the run-up to
|
|
291
|
+
* the crash" — a point-in-time snapshot of every signal value can't
|
|
292
|
+
* answer that (it shows the end state, not the causal sequence). The
|
|
293
|
+
* ring buffer records the last N writes so an error report can attach
|
|
294
|
+
* the sequence that led into the bad state.
|
|
295
|
+
*
|
|
296
|
+
* Design constraints:
|
|
297
|
+
*
|
|
298
|
+
* - **Bounded memory.** Fixed-size circular buffer (`CAP` entries).
|
|
299
|
+
* Never grows. Old entries overwrite oldest-first.
|
|
300
|
+
* - **No value retention.** Stores a TRUNCATED STRING preview of
|
|
301
|
+
* prev / next, never the raw value. Holding raw references would
|
|
302
|
+
* retain large arrays / detached DOM / closures in the buffer and
|
|
303
|
+
* leak them for the buffer's lifetime. The preview is also what
|
|
304
|
+
* makes the trace safely serializable into an error report.
|
|
305
|
+
* - **Cheap.** No stack capture (that's the expensive part of the
|
|
306
|
+
* `onSignalUpdate` debug path — this is deliberately lighter so it
|
|
307
|
+
* can record every write in dev without a perf cost). One object
|
|
308
|
+
* literal + one array slot write per signal write.
|
|
309
|
+
* - **Zero production cost.** The single call site in `signal.ts`
|
|
310
|
+
* is inside the existing `process.env.NODE_ENV !== 'production'`
|
|
311
|
+
* gate, so the whole module tree-shakes out of prod bundles.
|
|
312
|
+
*/
|
|
313
|
+
interface ReactiveTraceEntry {
|
|
314
|
+
/** Signal `label` (set via `signal(v, { name })` or the vite plugin's dev auto-naming). `undefined` for anonymous signals. */
|
|
315
|
+
name: string | undefined;
|
|
316
|
+
/** Bounded string preview of the value before the write. */
|
|
317
|
+
prev: string;
|
|
318
|
+
/** Bounded string preview of the value after the write. */
|
|
319
|
+
next: string;
|
|
320
|
+
/** `performance.now()` at write time (monotonic; survives clock changes). */
|
|
321
|
+
timestamp: number;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Returns the recorded writes in chronological order (oldest → newest),
|
|
325
|
+
* at most `CAP` entries. Empty array when nothing has been recorded.
|
|
326
|
+
* The returned array is a fresh copy — safe to retain / serialize
|
|
327
|
+
* without pinning the ring buffer.
|
|
328
|
+
*
|
|
329
|
+
* Consumed by `@pyreon/core`'s `reportError` to attach `reactiveTrace`
|
|
330
|
+
* to the error context.
|
|
331
|
+
*/
|
|
332
|
+
declare function getReactiveTrace(): ReactiveTraceEntry[];
|
|
333
|
+
/** Clears the buffer. For test isolation; not part of the app-facing API. */
|
|
334
|
+
declare function clearReactiveTrace(): void;
|
|
335
|
+
//#endregion
|
|
212
336
|
//#region src/effect.d.ts
|
|
213
337
|
interface Effect {
|
|
214
338
|
dispose(): void;
|
|
@@ -465,5 +589,5 @@ interface WatchOptions {
|
|
|
465
589
|
*/
|
|
466
590
|
declare function watch<T>(source: () => T, callback: (newVal: T, oldVal: T | undefined) => void | (() => void), opts?: WatchOptions): () => void;
|
|
467
591
|
//#endregion
|
|
468
|
-
export { Cell, type Computed, type ComputedOptions, type Effect, EffectScope, type ReactiveSnapshotCapture, type ReadonlySignal, type Resource, type Signal, type SignalDebugInfo, type SignalOptions, type WatchOptions, _bind, batch, cell, computed, createResource, createSelector, createStore, effect, effectScope, getCurrentScope, inspectSignal, isStore, markRaw, nextTick, onCleanup, onScopeDispose, onSignalUpdate, reconcile, renderEffect, runUntracked, runUntracked as untrack, setCurrentScope, setErrorHandler, setSnapshotCapture, shallowReactive, signal, watch, why };
|
|
592
|
+
export { Cell, type Computed, type ComputedOptions, type Effect, EffectScope, type ReactiveEdge, type ReactiveFire, type ReactiveGraph, type ReactiveNode, type ReactiveNodeKind, type ReactiveSnapshotCapture, type ReactiveTraceEntry, type ReadonlySignal, type Resource, type Signal, type SignalDebugInfo, type SignalOptions, type WatchOptions, _bind, activateReactiveDevtools, batch, cell, clearReactiveTrace, computed, createResource, createSelector, createStore, deactivateReactiveDevtools, effect, effectScope, getCurrentScope, getReactiveFires, getReactiveGraph, getReactiveTrace, inspectSignal, isReactiveDevtoolsActive, isStore, markRaw, nextTick, onCleanup, onScopeDispose, onSignalUpdate, reconcile, renderEffect, runUntracked, runUntracked as untrack, setCurrentScope, setErrorHandler, setSnapshotCapture, shallowReactive, signal, watch, why };
|
|
469
593
|
//# sourceMappingURL=index2.d.ts.map
|
package/package.json
CHANGED
package/src/computed.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { _markRecompute } from './batch'
|
|
2
2
|
import { _errorHandler } from './effect'
|
|
3
|
+
import { _rdRecordFire, _rdRegister } from './reactive-devtools'
|
|
3
4
|
import { getCurrentScope } from './scope'
|
|
4
5
|
import {
|
|
5
6
|
cleanupEffect,
|
|
@@ -87,21 +88,31 @@ function computedLazy<T>(fn: () => T): Computed<T> {
|
|
|
87
88
|
let tracked = false
|
|
88
89
|
const deps: Set<() => void>[] = []
|
|
89
90
|
const host: { _s: Set<() => void> | null } = { _s: null }
|
|
90
|
-
|
|
91
|
+
// Set, not a never-compacted flat array. The array form's disposal
|
|
92
|
+
// only nulled the slot (`arr[idx] = null`) and never shrank, so a
|
|
93
|
+
// long-lived computed (a derived theme/locale/auth value, or one read
|
|
94
|
+
// inside churning `<For>` rows) bound by mount/unmount churn grew one
|
|
95
|
+
// permanent dead slot per ever-registered binding — app-lifetime
|
|
96
|
+
// memory growth AND `recompute` iterating O(total-ever) instead of
|
|
97
|
+
// O(live). Identical bug class already fixed for `signal._d`
|
|
98
|
+
// (signal.ts `_directFn`); `computed` was left on the broken pattern.
|
|
99
|
+
let directFns: Set<() => void> | null = null
|
|
91
100
|
|
|
92
101
|
const recompute = () => {
|
|
93
102
|
if (disposed || dirty) return
|
|
94
103
|
dirty = true
|
|
95
104
|
if (host._s) notifySubscribers(host._s)
|
|
96
|
-
if (directFns) for (const f of directFns) f
|
|
105
|
+
if (directFns) for (const f of directFns) f()
|
|
97
106
|
}
|
|
98
107
|
_markRecompute(recompute)
|
|
99
108
|
|
|
100
109
|
const read = (): T => {
|
|
101
110
|
trackSubscriber(host)
|
|
102
111
|
if (dirty) {
|
|
103
|
-
if (process.env.NODE_ENV !== 'production')
|
|
112
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
104
113
|
_countSink.__pyreon_count__?.('reactivity.computedRecompute')
|
|
114
|
+
_rdRecordFire(read)
|
|
115
|
+
}
|
|
105
116
|
try {
|
|
106
117
|
if (tracked) {
|
|
107
118
|
// Deps already established from first run — skip adding to
|
|
@@ -136,16 +147,26 @@ function computedLazy<T>(fn: () => T): Computed<T> {
|
|
|
136
147
|
enumerable: false,
|
|
137
148
|
})
|
|
138
149
|
|
|
150
|
+
// @internal — mirrors `signal._d`. Lets tests deterministically assert
|
|
151
|
+
// the live direct-updater set stays BOUNDED under register/dispose
|
|
152
|
+
// churn (the never-compacted-array leak this fix removes).
|
|
153
|
+
Object.defineProperty(read, '_d', {
|
|
154
|
+
get: () => directFns,
|
|
155
|
+
enumerable: false,
|
|
156
|
+
})
|
|
157
|
+
|
|
139
158
|
read.direct = (updater: () => void): (() => void) => {
|
|
140
|
-
if (!directFns) directFns =
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
arr.push(updater)
|
|
159
|
+
if (!directFns) directFns = new Set()
|
|
160
|
+
const set = directFns
|
|
161
|
+
set.add(updater)
|
|
144
162
|
return () => {
|
|
145
|
-
|
|
163
|
+
set.delete(updater)
|
|
146
164
|
}
|
|
147
165
|
}
|
|
148
166
|
|
|
167
|
+
if (process.env.NODE_ENV !== 'production')
|
|
168
|
+
_rdRegister(read, 'derived', host, recompute, undefined)
|
|
169
|
+
|
|
149
170
|
getCurrentScope()?.add({ dispose: read.dispose })
|
|
150
171
|
return read as Computed<T>
|
|
151
172
|
}
|
|
@@ -163,12 +184,22 @@ function computedWithEquals<T>(fn: () => T, equals: (prev: T, next: T) => boolea
|
|
|
163
184
|
let disposed = false
|
|
164
185
|
const deps: Set<() => void>[] = []
|
|
165
186
|
const host: { _s: Set<() => void> | null } = { _s: null }
|
|
166
|
-
|
|
187
|
+
// Set, not a never-compacted flat array. The array form's disposal
|
|
188
|
+
// only nulled the slot (`arr[idx] = null`) and never shrank, so a
|
|
189
|
+
// long-lived computed (a derived theme/locale/auth value, or one read
|
|
190
|
+
// inside churning `<For>` rows) bound by mount/unmount churn grew one
|
|
191
|
+
// permanent dead slot per ever-registered binding — app-lifetime
|
|
192
|
+
// memory growth AND `recompute` iterating O(total-ever) instead of
|
|
193
|
+
// O(live). Identical bug class already fixed for `signal._d`
|
|
194
|
+
// (signal.ts `_directFn`); `computed` was left on the broken pattern.
|
|
195
|
+
let directFns: Set<() => void> | null = null
|
|
167
196
|
|
|
168
197
|
const recompute = () => {
|
|
169
198
|
if (disposed) return
|
|
170
|
-
if (process.env.NODE_ENV !== 'production')
|
|
199
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
171
200
|
_countSink.__pyreon_count__?.('reactivity.computedRecompute')
|
|
201
|
+
_rdRecordFire(read)
|
|
202
|
+
}
|
|
172
203
|
cleanupLocalDeps(deps, recompute)
|
|
173
204
|
try {
|
|
174
205
|
const next = trackWithLocalDeps(deps, recompute, fn)
|
|
@@ -181,7 +212,7 @@ function computedWithEquals<T>(fn: () => T, equals: (prev: T, next: T) => boolea
|
|
|
181
212
|
return
|
|
182
213
|
}
|
|
183
214
|
if (host._s) notifySubscribers(host._s)
|
|
184
|
-
if (directFns) for (const f of directFns) f
|
|
215
|
+
if (directFns) for (const f of directFns) f()
|
|
185
216
|
}
|
|
186
217
|
_markRecompute(recompute)
|
|
187
218
|
|
|
@@ -216,16 +247,26 @@ function computedWithEquals<T>(fn: () => T, equals: (prev: T, next: T) => boolea
|
|
|
216
247
|
enumerable: false,
|
|
217
248
|
})
|
|
218
249
|
|
|
250
|
+
// @internal — mirrors `signal._d`. Lets tests deterministically assert
|
|
251
|
+
// the live direct-updater set stays BOUNDED under register/dispose
|
|
252
|
+
// churn (the never-compacted-array leak this fix removes).
|
|
253
|
+
Object.defineProperty(read, '_d', {
|
|
254
|
+
get: () => directFns,
|
|
255
|
+
enumerable: false,
|
|
256
|
+
})
|
|
257
|
+
|
|
219
258
|
read.direct = (updater: () => void): (() => void) => {
|
|
220
|
-
if (!directFns) directFns =
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
arr.push(updater)
|
|
259
|
+
if (!directFns) directFns = new Set()
|
|
260
|
+
const set = directFns
|
|
261
|
+
set.add(updater)
|
|
224
262
|
return () => {
|
|
225
|
-
|
|
263
|
+
set.delete(updater)
|
|
226
264
|
}
|
|
227
265
|
}
|
|
228
266
|
|
|
267
|
+
if (process.env.NODE_ENV !== 'production')
|
|
268
|
+
_rdRegister(read, 'derived', host, recompute, undefined)
|
|
269
|
+
|
|
229
270
|
getCurrentScope()?.add({ dispose: read.dispose })
|
|
230
271
|
return read as Computed<T>
|
|
231
272
|
}
|
package/src/effect.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { _rdRecordFire, _rdRegister } from './reactive-devtools'
|
|
1
2
|
import { getCurrentScope } from './scope'
|
|
2
3
|
import { _restoreActiveEffect, _setActiveEffect, setDepsCollector, withTracking } from './tracking'
|
|
3
4
|
|
|
@@ -102,6 +103,11 @@ interface PyreonErrorBridge {
|
|
|
102
103
|
const _errorBridge = globalThis as PyreonErrorBridge
|
|
103
104
|
|
|
104
105
|
function _defaultErrorHandler(err: unknown): void {
|
|
106
|
+
// Last-resort unhandled-effect-error reporter — MUST fire in
|
|
107
|
+
// production (silently swallowing uncaught effect errors is a
|
|
108
|
+
// serious bug; React/Vue/Solid all log uncaught errors in prod).
|
|
109
|
+
// Deliberately not __DEV__-gated.
|
|
110
|
+
// pyreon-lint-disable-next-line pyreon/dev-guard-warnings
|
|
105
111
|
console.error('[pyreon] Unhandled effect error:', err)
|
|
106
112
|
}
|
|
107
113
|
|
|
@@ -206,8 +212,10 @@ export function effect(fn: () => (() => void) | void): Effect {
|
|
|
206
212
|
|
|
207
213
|
const run = () => {
|
|
208
214
|
if (disposed) return
|
|
209
|
-
if (process.env.NODE_ENV !== 'production')
|
|
215
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
210
216
|
_countSink.__pyreon_count__?.('reactivity.effectRun')
|
|
217
|
+
_rdRecordFire(run)
|
|
218
|
+
}
|
|
211
219
|
// Run previous cleanup before re-running
|
|
212
220
|
runCleanup()
|
|
213
221
|
// Start a new inner-effect collection window. Effects created during
|
|
@@ -249,6 +257,9 @@ export function effect(fn: () => (() => void) | void): Effect {
|
|
|
249
257
|
isFirstRun = false
|
|
250
258
|
}
|
|
251
259
|
|
|
260
|
+
if (process.env.NODE_ENV !== 'production')
|
|
261
|
+
_rdRegister(run, 'effect', null, run, undefined)
|
|
262
|
+
|
|
252
263
|
run()
|
|
253
264
|
|
|
254
265
|
const e: Effect = {
|
|
@@ -404,6 +415,9 @@ export function renderEffect(fn: () => void): () => void {
|
|
|
404
415
|
}
|
|
405
416
|
}
|
|
406
417
|
|
|
418
|
+
if (process.env.NODE_ENV !== 'production')
|
|
419
|
+
_rdRegister(run, 'effect', null, run, undefined)
|
|
420
|
+
|
|
407
421
|
run()
|
|
408
422
|
|
|
409
423
|
const dispose = () => {
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,22 @@ 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 type {
|
|
9
|
+
ReactiveEdge,
|
|
10
|
+
ReactiveFire,
|
|
11
|
+
ReactiveGraph,
|
|
12
|
+
ReactiveNode,
|
|
13
|
+
ReactiveNodeKind,
|
|
14
|
+
} from './reactive-devtools'
|
|
15
|
+
export {
|
|
16
|
+
activateReactiveDevtools,
|
|
17
|
+
deactivateReactiveDevtools,
|
|
18
|
+
getReactiveFires,
|
|
19
|
+
getReactiveGraph,
|
|
20
|
+
isReactiveDevtoolsActive,
|
|
21
|
+
} from './reactive-devtools'
|
|
22
|
+
export type { ReactiveTraceEntry } from './reactive-trace'
|
|
23
|
+
export { clearReactiveTrace, getReactiveTrace } from './reactive-trace'
|
|
8
24
|
export {
|
|
9
25
|
_bind,
|
|
10
26
|
type Effect,
|
package/src/manifest.ts
CHANGED
|
@@ -58,7 +58,7 @@ effect(() => {
|
|
|
58
58
|
'createStore() / reconcile() / isStore() — deeply reactive proxy stores + structural diff',
|
|
59
59
|
'effectScope() / getCurrentScope() — scope-based lifecycle management',
|
|
60
60
|
'untrack() — read without subscribing',
|
|
61
|
-
'onSignalUpdate() / inspectSignal() / why() — debug instrumentation',
|
|
61
|
+
'onSignalUpdate() / inspectSignal() / why() / getReactiveTrace() — debug instrumentation',
|
|
62
62
|
'setErrorHandler() — global hook for unhandled effect errors',
|
|
63
63
|
'Standalone — zero DOM, zero JSX, zero framework dependency',
|
|
64
64
|
],
|
|
@@ -553,6 +553,27 @@ why() // disarm + dump transcript:
|
|
|
553
553
|
],
|
|
554
554
|
seeAlso: ['onSignalUpdate', 'inspectSignal'],
|
|
555
555
|
},
|
|
556
|
+
{
|
|
557
|
+
name: 'getReactiveTrace',
|
|
558
|
+
kind: 'function',
|
|
559
|
+
signature:
|
|
560
|
+
'() => Array<{ name: string | undefined; prev: string; next: string; timestamp: number }>',
|
|
561
|
+
summary:
|
|
562
|
+
'Returns the last ~50 signal writes (chronological, oldest → newest) from a bounded dev-only ring buffer — the causal SEQUENCE of reactive state changes, not a point-in-time snapshot. `@pyreon/core` attaches this to `ErrorContext.reactiveTrace` automatically so error reports carry "what changed in the run-up to the crash". Entries hold bounded string previews of values (never raw refs — no memory pinning, always serializable). **Dev-only**: the recorder feeding the buffer is behind the production dead-code gate and tree-shakes out, so this returns `[]` in prod builds. Distinct from `onSignalUpdate` — that is opt-in and captures stacks; this is always-on, deliberately cheap, and exists to enrich error reports. `clearReactiveTrace()` resets it (test isolation).',
|
|
563
|
+
example: `import { getReactiveTrace, clearReactiveTrace, signal } from '@pyreon/reactivity'
|
|
564
|
+
|
|
565
|
+
const status = signal('idle', { name: 'status' })
|
|
566
|
+
status.set('submitting')
|
|
567
|
+
getReactiveTrace()
|
|
568
|
+
// [{ name: 'status', prev: '"idle"', next: '"submitting"', timestamp: 1234.5 }]
|
|
569
|
+
clearReactiveTrace() // → []`,
|
|
570
|
+
mistakes: [
|
|
571
|
+
'Expecting it to return signal VALUES — it returns string PREVIEWS (truncated, safely stringified). For live values inspect the signal directly',
|
|
572
|
+
'Relying on it in production — returns `[]` (the recorder is dev-gated and tree-shaken). Use it for dev tooling / error-report enrichment, not runtime logic',
|
|
573
|
+
'Treating it as a snapshot of all signals — it is a bounded ring of recent WRITES; signals never written (or written before the ~50-entry window) are absent',
|
|
574
|
+
],
|
|
575
|
+
seeAlso: ['onSignalUpdate', 'inspectSignal'],
|
|
576
|
+
},
|
|
556
577
|
{
|
|
557
578
|
name: 'setErrorHandler',
|
|
558
579
|
kind: 'function',
|
|
@@ -575,6 +596,52 @@ count.set(101) // logs/reports via handler instead of crashing`,
|
|
|
575
596
|
],
|
|
576
597
|
seeAlso: ['effect', 'renderEffect'],
|
|
577
598
|
},
|
|
599
|
+
{
|
|
600
|
+
name: 'activateReactiveDevtools',
|
|
601
|
+
kind: 'function',
|
|
602
|
+
signature:
|
|
603
|
+
'activateReactiveDevtools(): void · deactivateReactiveDevtools(): void · isReactiveDevtoolsActive(): boolean',
|
|
604
|
+
summary:
|
|
605
|
+
'Opt-in lifecycle for the reactive-devtools bridge — the live signal/computed/effect graph the `@pyreon/devtools` Signals/Graph/Effects/Profiler tabs consume (surfaced on the browser hook as `window.__PYREON_DEVTOOLS__.reactive`). **Zero cost until activated**: every per-primitive instrumentation point early-returns on the inactive flag and sits inside the production dead-code gate, so it tree-shakes out of prod builds entirely (locked by a minified-bundle test) and, in dev, costs one predicted-false branch until a devtools client calls `activate()` — the same risk profile as the adjacent reactive-trace / perf-harness calls. `deactivate()` drops all retained registry + fire-buffer state (a closed panel leaves zero residue). Leak-free by construction: nodes are held via `WeakRef` + `FinalizationRegistry`, never pinned.',
|
|
606
|
+
example: `import { activateReactiveDevtools, getReactiveGraph } from '@pyreon/reactivity'
|
|
607
|
+
|
|
608
|
+
// Only AFTER activation are subsequently-created signals tracked.
|
|
609
|
+
activateReactiveDevtools()
|
|
610
|
+
const price = signal(10, { name: '$price' })
|
|
611
|
+
const total = computed(() => price() * 2)
|
|
612
|
+
effect(() => total())
|
|
613
|
+
getReactiveGraph().nodes // → [$price (signal), derived, effect]
|
|
614
|
+
deactivateReactiveDevtools() // → registry cleared`,
|
|
615
|
+
mistakes: [
|
|
616
|
+
'Expecting nodes created BEFORE `activate()` to appear — registration is gated on the active flag (mirrors a devtools panel attaching). Activate first, then build/observe the graph',
|
|
617
|
+
'Calling it in production for app logic — the whole bridge is dev-gated and tree-shaken; `getReactiveGraph()` returns an empty graph in prod builds',
|
|
618
|
+
'Assuming it tracks compiler-emitted DOM bindings — only user `signal()` / `computed()` / `effect()` are registered; `renderEffect` / `_bind` plumbing is intentionally excluded (it would flood the graph and tax the hottest path)',
|
|
619
|
+
],
|
|
620
|
+
seeAlso: ['getReactiveGraph', 'onSignalUpdate', 'getReactiveTrace'],
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
name: 'getReactiveGraph',
|
|
624
|
+
kind: 'function',
|
|
625
|
+
signature:
|
|
626
|
+
'getReactiveGraph(): { nodes: ReactiveNode[]; edges: { from: number; to: number }[] } · getReactiveFires(): { id: number; ts: number }[]',
|
|
627
|
+
summary:
|
|
628
|
+
'Fresh snapshot of the live reactive graph + a bounded recent-fire timeline, for the reactive-devtools tabs. `getReactiveGraph()` returns every tracked node (`{ id, kind: "signal"|"derived"|"effect", name, value, subscribers, fires, lastFire }`) plus dependency edges recomputed on demand from the real subscriber `_s` Sets (source → subscriber: signal→derived, derived→effect) — always consistent with the framework’s actual subscription state, no incremental drift. `getReactiveFires()` returns a fixed-size ring buffer of recent fires (`{ id, ts }`, oldest → newest) powering the Effects/Profiler tabs. Both require `activateReactiveDevtools()` first and return empty otherwise. Names come from `signal(v, { name })` / the vite-plugin dev auto-naming; anonymous computeds/effects get a synthetic `derived#id` / `effect#id`.',
|
|
629
|
+
example: `activateReactiveDevtools()
|
|
630
|
+
const a = signal(1, { name: '$a' })
|
|
631
|
+
const b = computed(() => a() + 1)
|
|
632
|
+
effect(() => b())
|
|
633
|
+
a.set(2)
|
|
634
|
+
getReactiveGraph()
|
|
635
|
+
// nodes: [{ name:'$a', kind:'signal', value:'2', … }, { kind:'derived', … }, { kind:'effect', … }]
|
|
636
|
+
// edges: [{ from:$a, to:derived }, { from:derived, to:effect }]
|
|
637
|
+
getReactiveFires() // → [{ id, ts }, …] (bounded, chronological)`,
|
|
638
|
+
mistakes: [
|
|
639
|
+
'Holding the returned arrays expecting them to update — they are point-in-time snapshots; call again (the devtools panel polls)',
|
|
640
|
+
'Reading `node.value` for non-string state as the real value — it is a bounded, safely-stringified PREVIEW (never a raw ref — no pinning). Inspect the signal directly for the live value',
|
|
641
|
+
'Expecting fires for every write in a long-running app — `getReactiveFires()` is a fixed-size ring; older entries roll off',
|
|
642
|
+
],
|
|
643
|
+
seeAlso: ['activateReactiveDevtools', 'getReactiveTrace', 'onSignalUpdate'],
|
|
644
|
+
},
|
|
578
645
|
],
|
|
579
646
|
gotchas: [
|
|
580
647
|
{
|