@pyreon/reactivity 0.24.4 → 0.24.6
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/package.json +1 -4
- package/src/batch.ts +0 -196
- package/src/cell.ts +0 -72
- package/src/computed.ts +0 -313
- package/src/createSelector.ts +0 -109
- package/src/debug.ts +0 -134
- package/src/effect.ts +0 -467
- package/src/env.d.ts +0 -6
- package/src/index.ts +0 -60
- package/src/lpih.ts +0 -227
- package/src/manifest.ts +0 -660
- package/src/reactive-devtools.ts +0 -494
- package/src/reactive-trace.ts +0 -142
- package/src/reconcile.ts +0 -118
- package/src/resource.ts +0 -84
- package/src/scope.ts +0 -123
- package/src/signal.ts +0 -261
- package/src/store.ts +0 -250
- package/src/tests/batch.test.ts +0 -751
- package/src/tests/bind.test.ts +0 -84
- package/src/tests/branches.test.ts +0 -343
- package/src/tests/cell.test.ts +0 -159
- package/src/tests/computed.test.ts +0 -436
- package/src/tests/coverage-hardening.test.ts +0 -471
- package/src/tests/createSelector.test.ts +0 -291
- package/src/tests/debug.test.ts +0 -196
- package/src/tests/effect.test.ts +0 -464
- package/src/tests/fanout-repro.test.ts +0 -179
- package/src/tests/lpih-source-location.test.ts +0 -277
- package/src/tests/lpih.test.ts +0 -351
- package/src/tests/manifest-snapshot.test.ts +0 -96
- package/src/tests/reactive-devtools-treeshake.test.ts +0 -48
- package/src/tests/reactive-devtools.test.ts +0 -296
- package/src/tests/reactive-trace.test.ts +0 -102
- package/src/tests/reconcile-security.test.ts +0 -45
- package/src/tests/resource.test.ts +0 -326
- package/src/tests/scope.test.ts +0 -231
- package/src/tests/signal.test.ts +0 -368
- package/src/tests/store.test.ts +0 -286
- package/src/tests/tracking.test.ts +0 -158
- package/src/tests/vue-parity.test.ts +0 -191
- package/src/tests/watch.test.ts +0 -246
- package/src/tracking.ts +0 -139
- package/src/watch.ts +0 -68
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/reactivity",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.6",
|
|
4
4
|
"description": "Signals-based reactivity system for Pyreon",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/reactivity#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
"files": [
|
|
16
16
|
"lib",
|
|
17
17
|
"!lib/**/*.map",
|
|
18
|
-
"src",
|
|
19
18
|
"README.md",
|
|
20
19
|
"LICENSE"
|
|
21
20
|
],
|
|
@@ -26,12 +25,10 @@
|
|
|
26
25
|
"types": "./lib/types/index.d.ts",
|
|
27
26
|
"exports": {
|
|
28
27
|
".": {
|
|
29
|
-
"bun": "./src/index.ts",
|
|
30
28
|
"import": "./lib/index.js",
|
|
31
29
|
"types": "./lib/types/index.d.ts"
|
|
32
30
|
},
|
|
33
31
|
"./lpih": {
|
|
34
|
-
"bun": "./src/lpih.ts",
|
|
35
32
|
"import": "./lib/lpih.js",
|
|
36
33
|
"types": "./lib/types/lpih.d.ts"
|
|
37
34
|
}
|
package/src/batch.ts
DELETED
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
// Batch multiple signal updates into a single notification pass.
|
|
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'
|
|
9
|
-
|
|
10
|
-
let batchDepth = 0
|
|
11
|
-
|
|
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
|
-
}
|
|
64
|
-
|
|
65
|
-
export function batch(fn: () => void): void {
|
|
66
|
-
batchDepth++
|
|
67
|
-
try {
|
|
68
|
-
fn()
|
|
69
|
-
} finally {
|
|
70
|
-
batchDepth--
|
|
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
|
-
// Surface labels of dropped effects when available — helps
|
|
106
|
-
// identify the offending effect in a real app. Falls back to
|
|
107
|
-
// bare count for anonymous effects.
|
|
108
|
-
const droppedCount = pendingEffects.size
|
|
109
|
-
const labels: string[] = []
|
|
110
|
-
for (const notify of pendingEffects) {
|
|
111
|
-
const label = (notify as { _label?: string })._label
|
|
112
|
-
if (label) labels.push(label)
|
|
113
|
-
if (labels.length >= 5) break
|
|
114
|
-
}
|
|
115
|
-
const labelHint = labels.length
|
|
116
|
-
? ` Sample labels: ${labels.join(', ')}${droppedCount > labels.length ? `, …${droppedCount - labels.length} more` : ''}.`
|
|
117
|
-
: ''
|
|
118
|
-
// oxlint-disable-next-line no-console
|
|
119
|
-
console.warn(
|
|
120
|
-
'[pyreon] batch effect flush exceeded MAX_PASSES (32) — possible infinite re-enqueue loop. ' +
|
|
121
|
-
`${droppedCount} pending effects dropped.${labelHint} ` +
|
|
122
|
-
'Common cause: an effect that writes to a signal it also reads, without a guard. ' +
|
|
123
|
-
'See packages/core/reactivity/src/batch.ts for the multi-pass flush contract.',
|
|
124
|
-
)
|
|
125
|
-
}
|
|
126
|
-
// Drop the queue so subsequent batches start clean — without
|
|
127
|
-
// this, the next batch would re-encounter the offending effect
|
|
128
|
-
// immediately on its first pass and trip MAX_PASSES instantly,
|
|
129
|
-
// making the original error harder to diagnose.
|
|
130
|
-
pendingEffects.clear()
|
|
131
|
-
_nextEffectPass.clear()
|
|
132
|
-
break
|
|
133
|
-
}
|
|
134
|
-
_visitedThisPass = new Set<() => void>()
|
|
135
|
-
for (const notify of pendingEffects) {
|
|
136
|
-
_visitedThisPass.add(notify)
|
|
137
|
-
notify()
|
|
138
|
-
}
|
|
139
|
-
// Promote next-pass entries to pending for the next iteration.
|
|
140
|
-
pendingEffects.clear()
|
|
141
|
-
for (const next of _nextEffectPass) pendingEffects.add(next)
|
|
142
|
-
_nextEffectPass.clear()
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
} finally {
|
|
146
|
-
// Clear ALWAYS — even if a notify threw mid-iteration. Without this,
|
|
147
|
-
// the unflushed remainder leaks into the next batch and refires
|
|
148
|
-
// (audit bug #19). Effects wrap their callbacks in try/catch
|
|
149
|
-
// internally so this is rarely reachable in practice, but raw
|
|
150
|
-
// signal subscribers (signal.subscribe) and lower-level consumers
|
|
151
|
-
// can throw straight through, and a future refactor that swallows
|
|
152
|
-
// less aggressively would silently regress without this guard.
|
|
153
|
-
pendingRecomputes.clear()
|
|
154
|
-
pendingEffects.clear()
|
|
155
|
-
_nextEffectPass.clear()
|
|
156
|
-
_visitedThisPass = null
|
|
157
|
-
batchDepth = 0
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export function isBatching(): boolean {
|
|
164
|
-
return batchDepth > 0
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export function enquePendingNotificationDeprecated(): void {
|
|
168
|
-
// Kept as a comment placeholder — actual export is below. (Empty body to
|
|
169
|
-
// keep this file's exports list stable across the refactor.)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export function enqueuePendingNotification(notify: () => void): void {
|
|
173
|
-
// Route based on callback kind. Computed recomputes go to tier-1 queue,
|
|
174
|
-
// effects to tier-2. Within tier 2, already-visited-this-pass entries
|
|
175
|
-
// route to next-pass for cross-pass re-fire (ErrorBoundary's pattern).
|
|
176
|
-
if (_recomputes.has(notify)) {
|
|
177
|
-
pendingRecomputes.add(notify)
|
|
178
|
-
} else if (_visitedThisPass !== null && _visitedThisPass.has(notify)) {
|
|
179
|
-
_nextEffectPass.add(notify)
|
|
180
|
-
} else {
|
|
181
|
-
pendingEffects.add(notify)
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Returns a Promise that resolves after all currently-pending microtasks have flushed.
|
|
187
|
-
* Useful when you need to read the DOM after a batch of signal updates has settled.
|
|
188
|
-
*
|
|
189
|
-
* @example
|
|
190
|
-
* count.set(1); count.set(2)
|
|
191
|
-
* await nextTick()
|
|
192
|
-
* // DOM is now up-to-date
|
|
193
|
-
*/
|
|
194
|
-
export function nextTick(): Promise<void> {
|
|
195
|
-
return new Promise((resolve) => queueMicrotask(resolve))
|
|
196
|
-
}
|
package/src/cell.ts
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Lightweight reactive cell — class-based alternative to signal().
|
|
3
|
-
*
|
|
4
|
-
* - 1 object allocation vs signal()'s 6 closures
|
|
5
|
-
* - Same API surface: peek(), set(), update(), subscribe(), listen()
|
|
6
|
-
* - NOT callable as a getter (no effect tracking) — use for fixed subscriptions
|
|
7
|
-
* - Methods on prototype, shared across all instances
|
|
8
|
-
* - Single-listener fast path: no Set allocated when ≤1 subscriber
|
|
9
|
-
*
|
|
10
|
-
* Use when you need reactive state but don't need automatic effect dependency tracking.
|
|
11
|
-
* Ideal for list item labels in keyed reconcilers where subscribe() is used directly.
|
|
12
|
-
*/
|
|
13
|
-
export class Cell<T> {
|
|
14
|
-
/** @internal */ _v: T
|
|
15
|
-
/** @internal */ _l: (() => void) | null = null // single-listener fast path
|
|
16
|
-
/** @internal */ _s: Set<() => void> | null = null // multi-listener fallback
|
|
17
|
-
|
|
18
|
-
constructor(value: T) {
|
|
19
|
-
this._v = value
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
peek(): T {
|
|
23
|
-
return this._v
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
set(value: T): void {
|
|
27
|
-
if (Object.is(this._v, value)) return
|
|
28
|
-
this._v = value
|
|
29
|
-
if (this._l) this._l()
|
|
30
|
-
else if (this._s) for (const fn of this._s) fn()
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
update(fn: (current: T) => T): void {
|
|
34
|
-
this.set(fn(this._v))
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Fire-and-forget subscription — no unsubscribe returned.
|
|
39
|
-
* Use when the listener's lifetime matches the cell's (e.g., list rows).
|
|
40
|
-
* Saves 1 closure allocation per call vs subscribe().
|
|
41
|
-
*/
|
|
42
|
-
listen(listener: () => void): void {
|
|
43
|
-
if (!this._l && !this._s) {
|
|
44
|
-
this._l = listener
|
|
45
|
-
} else {
|
|
46
|
-
// Promote to Set
|
|
47
|
-
if (!this._s) {
|
|
48
|
-
this._s = new Set()
|
|
49
|
-
if (this._l) {
|
|
50
|
-
this._s.add(this._l)
|
|
51
|
-
this._l = null
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
this._s.add(listener)
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
subscribe(listener: () => void): () => void {
|
|
59
|
-
this.listen(listener)
|
|
60
|
-
// The listener could be in _l (single) or _s (multi).
|
|
61
|
-
// A later subscribe() call may promote it from _l to _s,
|
|
62
|
-
// so the disposer must check both locations.
|
|
63
|
-
return () => {
|
|
64
|
-
if (this._l === listener) this._l = null
|
|
65
|
-
else this._s?.delete(listener)
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function cell<T>(value: T): Cell<T> {
|
|
71
|
-
return new Cell(value)
|
|
72
|
-
}
|
package/src/computed.ts
DELETED
|
@@ -1,313 +0,0 @@
|
|
|
1
|
-
import { _markRecompute } from './batch'
|
|
2
|
-
import { _errorHandler } from './effect'
|
|
3
|
-
import { _captureCallerLocation, _rdRecordFire, _rdRegister } from './reactive-devtools'
|
|
4
|
-
import { getCurrentScope } from './scope'
|
|
5
|
-
import {
|
|
6
|
-
cleanupEffect,
|
|
7
|
-
notifySubscribers,
|
|
8
|
-
setDepsCollector,
|
|
9
|
-
setSkipDepsCollection,
|
|
10
|
-
trackSubscriber,
|
|
11
|
-
withTracking,
|
|
12
|
-
} from './tracking'
|
|
13
|
-
|
|
14
|
-
// Dev-time counter sink — see packages/internals/perf-harness for contract.
|
|
15
|
-
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
|
16
|
-
|
|
17
|
-
export interface Computed<T> {
|
|
18
|
-
(): T
|
|
19
|
-
/** Remove this computed from all its reactive dependencies. */
|
|
20
|
-
dispose(): void
|
|
21
|
-
/** Cached value for compiler-emitted direct bindings (_bindText, _bindDirect). */
|
|
22
|
-
_v: T
|
|
23
|
-
/** Register a direct updater — used by compiler-emitted _bindText/_bindDirect. */
|
|
24
|
-
direct(updater: () => void): () => void
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface ComputedOptions<T> {
|
|
28
|
-
/**
|
|
29
|
-
* Custom equality function. When provided, the computed eagerly re-evaluates
|
|
30
|
-
* on dependency change and only notifies downstream if `equals(prev, next)`
|
|
31
|
-
* returns false. Useful for derived objects/arrays to skip spurious updates.
|
|
32
|
-
*
|
|
33
|
-
* @example
|
|
34
|
-
* const sorted = computed(() => items().slice().sort(), {
|
|
35
|
-
* equals: (a, b) => a.length === b.length && a.every((v, i) => v === b[i])
|
|
36
|
-
* })
|
|
37
|
-
*/
|
|
38
|
-
equals?: (prev: T, next: T) => boolean
|
|
39
|
-
/**
|
|
40
|
-
* @internal — source location injected by `@pyreon/vite-plugin` at build
|
|
41
|
-
* time. When present, the runtime skips the `new Error().stack` capture
|
|
42
|
-
* in `_rdRegister` — saves ~2.2µs per computed creation when devtools is
|
|
43
|
-
* active. Plain user code should NOT set this; the field is opaque
|
|
44
|
-
* (no public type) so it's not part of the public API surface.
|
|
45
|
-
*
|
|
46
|
-
* Shape: `{ file: string; line: number; col: number }` matching
|
|
47
|
-
* `@pyreon/reactivity`'s `SourceLocation`.
|
|
48
|
-
*/
|
|
49
|
-
__sourceLocation?: { file: string; line: number; col: number }
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Remove a computed from all dependency subscriber sets (local deps array). */
|
|
53
|
-
function cleanupLocalDeps(deps: Set<() => void>[], fn: () => void): void {
|
|
54
|
-
for (let i = 0; i < deps.length; i++) (deps[i] as Set<() => void>).delete(fn)
|
|
55
|
-
deps.length = 0
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Re-track dependencies using the local deps array collector. */
|
|
59
|
-
function trackWithLocalDeps<T>(deps: Set<() => void>[], effect: () => void, fn: () => T): T {
|
|
60
|
-
setDepsCollector(deps)
|
|
61
|
-
const result = withTracking(effect, fn)
|
|
62
|
-
setDepsCollector(null)
|
|
63
|
-
return result
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function computed<T>(fn: () => T, options?: ComputedOptions<T>): Computed<T> {
|
|
67
|
-
// Dev warning for async computed callbacks (audit bug #1 — extension).
|
|
68
|
-
// `computed(async () => …)` returns `Computed<Promise<T>>`, which silently
|
|
69
|
-
// breaks every consumer that expects `Computed<T>`. There's no scenario
|
|
70
|
-
// where async makes sense here — the recompute fires synchronously and
|
|
71
|
-
// tracks signals only in the synchronous prefix. For async-derived
|
|
72
|
-
// state, use `createResource` or a `signal<T>` updated from an effect.
|
|
73
|
-
if (process.env.NODE_ENV !== 'production') {
|
|
74
|
-
if (fn.constructor && fn.constructor.name === 'AsyncFunction') {
|
|
75
|
-
// oxlint-disable-next-line no-console
|
|
76
|
-
console.warn(
|
|
77
|
-
'[pyreon] computed() received an async function. The result type becomes `Computed<Promise<T>>`, and signal reads after the first `await` are NOT tracked. ' +
|
|
78
|
-
'Use `createResource` for async-derived state, or compute synchronously over a signal that holds the awaited value.',
|
|
79
|
-
)
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
// Prefer build-time-injected location (zero runtime cost) over the
|
|
83
|
-
// ~2.2µs stack-capture fallback. @pyreon/vite-plugin's `injectSignalNames`
|
|
84
|
-
// rewrites `computed(() => …)` to `computed(() => …, { __sourceLocation: {…} })`
|
|
85
|
-
// at transform time so most dev-mode computeds never pay the stack-capture cost.
|
|
86
|
-
const loc = options?.__sourceLocation
|
|
87
|
-
return options?.equals
|
|
88
|
-
? computedWithEquals(fn, options.equals, loc)
|
|
89
|
-
: computedLazy(fn, loc)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Default computed — lazy evaluation with deferred cleanup.
|
|
94
|
-
*
|
|
95
|
-
* On notification: just marks dirty and propagates (no cleanup/re-track).
|
|
96
|
-
* On read: cleans up old deps, re-evaluates, re-tracks.
|
|
97
|
-
*
|
|
98
|
-
* The `if (dirty) return` early exit in recompute prevents double-propagation
|
|
99
|
-
* in diamond patterns (a→b,c→d: b notifies d, c tries to notify d again —
|
|
100
|
-
* skipped because d is already dirty).
|
|
101
|
-
*/
|
|
102
|
-
function computedLazy<T>(
|
|
103
|
-
fn: () => T,
|
|
104
|
-
injectedLoc?: { file: string; line: number; col: number },
|
|
105
|
-
): Computed<T> {
|
|
106
|
-
let value: T
|
|
107
|
-
let dirty = true
|
|
108
|
-
let disposed = false
|
|
109
|
-
let tracked = false
|
|
110
|
-
const deps: Set<() => void>[] = []
|
|
111
|
-
const host: { _s: Set<() => void> | null } = { _s: null }
|
|
112
|
-
// Set, not a never-compacted flat array. The array form's disposal
|
|
113
|
-
// only nulled the slot (`arr[idx] = null`) and never shrank, so a
|
|
114
|
-
// long-lived computed (a derived theme/locale/auth value, or one read
|
|
115
|
-
// inside churning `<For>` rows) bound by mount/unmount churn grew one
|
|
116
|
-
// permanent dead slot per ever-registered binding — app-lifetime
|
|
117
|
-
// memory growth AND `recompute` iterating O(total-ever) instead of
|
|
118
|
-
// O(live). Identical bug class already fixed for `signal._d`
|
|
119
|
-
// (signal.ts `_directFn`); `computed` was left on the broken pattern.
|
|
120
|
-
let directFns: Set<() => void> | null = null
|
|
121
|
-
|
|
122
|
-
const recompute = () => {
|
|
123
|
-
if (disposed || dirty) return
|
|
124
|
-
dirty = true
|
|
125
|
-
if (host._s) notifySubscribers(host._s)
|
|
126
|
-
if (directFns) for (const f of directFns) f()
|
|
127
|
-
}
|
|
128
|
-
_markRecompute(recompute)
|
|
129
|
-
|
|
130
|
-
const read = (): T => {
|
|
131
|
-
trackSubscriber(host)
|
|
132
|
-
if (dirty) {
|
|
133
|
-
if (process.env.NODE_ENV !== 'production') {
|
|
134
|
-
_countSink.__pyreon_count__?.('reactivity.computedRecompute')
|
|
135
|
-
_rdRecordFire(read)
|
|
136
|
-
}
|
|
137
|
-
try {
|
|
138
|
-
if (tracked) {
|
|
139
|
-
// Deps already established from first run — skip adding to
|
|
140
|
-
// subscriber Sets again (they already contain recompute).
|
|
141
|
-
// Still need withTracking so activeEffect is set correctly
|
|
142
|
-
// for any NEW signals read on this evaluation.
|
|
143
|
-
setSkipDepsCollection(true)
|
|
144
|
-
value = withTracking(recompute, fn)
|
|
145
|
-
setSkipDepsCollection(false)
|
|
146
|
-
} else {
|
|
147
|
-
value = trackWithLocalDeps(deps, recompute, fn)
|
|
148
|
-
tracked = true
|
|
149
|
-
}
|
|
150
|
-
} catch (err) {
|
|
151
|
-
_errorHandler(err)
|
|
152
|
-
}
|
|
153
|
-
dirty = false
|
|
154
|
-
}
|
|
155
|
-
return value as T
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
read.dispose = () => {
|
|
159
|
-
disposed = true
|
|
160
|
-
cleanupLocalDeps(deps, recompute)
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
Object.defineProperty(read, '_v', {
|
|
164
|
-
get: () => {
|
|
165
|
-
if (dirty) read() // ensure value is fresh
|
|
166
|
-
return value
|
|
167
|
-
},
|
|
168
|
-
enumerable: false,
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
// @internal — mirrors `signal._d`. Lets tests deterministically assert
|
|
172
|
-
// the live direct-updater set stays BOUNDED under register/dispose
|
|
173
|
-
// churn (the never-compacted-array leak this fix removes).
|
|
174
|
-
Object.defineProperty(read, '_d', {
|
|
175
|
-
get: () => directFns,
|
|
176
|
-
enumerable: false,
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
read.direct = (updater: () => void): (() => void) => {
|
|
180
|
-
if (!directFns) directFns = new Set()
|
|
181
|
-
const set = directFns
|
|
182
|
-
set.add(updater)
|
|
183
|
-
return () => {
|
|
184
|
-
set.delete(updater)
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (process.env.NODE_ENV !== 'production')
|
|
189
|
-
// skipFrames=2: skip computedLazy/computedWithEquals + computed, capture user's call site.
|
|
190
|
-
_rdRegister(
|
|
191
|
-
read,
|
|
192
|
-
'derived',
|
|
193
|
-
host,
|
|
194
|
-
recompute,
|
|
195
|
-
undefined,
|
|
196
|
-
injectedLoc ?? _captureCallerLocation(2),
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
getCurrentScope()?.add({ dispose: read.dispose })
|
|
200
|
-
return read as Computed<T>
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Computed with custom equality — eager evaluation on notification.
|
|
205
|
-
*
|
|
206
|
-
* Re-evaluates immediately when deps change and only notifies downstream
|
|
207
|
-
* if `equals(prev, next)` returns false.
|
|
208
|
-
*/
|
|
209
|
-
function computedWithEquals<T>(
|
|
210
|
-
fn: () => T,
|
|
211
|
-
equals: (prev: T, next: T) => boolean,
|
|
212
|
-
injectedLoc?: { file: string; line: number; col: number },
|
|
213
|
-
): Computed<T> {
|
|
214
|
-
let value: T
|
|
215
|
-
let dirty = true
|
|
216
|
-
let initialized = false
|
|
217
|
-
let disposed = false
|
|
218
|
-
const deps: Set<() => void>[] = []
|
|
219
|
-
const host: { _s: Set<() => void> | null } = { _s: null }
|
|
220
|
-
// Set, not a never-compacted flat array. The array form's disposal
|
|
221
|
-
// only nulled the slot (`arr[idx] = null`) and never shrank, so a
|
|
222
|
-
// long-lived computed (a derived theme/locale/auth value, or one read
|
|
223
|
-
// inside churning `<For>` rows) bound by mount/unmount churn grew one
|
|
224
|
-
// permanent dead slot per ever-registered binding — app-lifetime
|
|
225
|
-
// memory growth AND `recompute` iterating O(total-ever) instead of
|
|
226
|
-
// O(live). Identical bug class already fixed for `signal._d`
|
|
227
|
-
// (signal.ts `_directFn`); `computed` was left on the broken pattern.
|
|
228
|
-
let directFns: Set<() => void> | null = null
|
|
229
|
-
|
|
230
|
-
const recompute = () => {
|
|
231
|
-
if (disposed) return
|
|
232
|
-
if (process.env.NODE_ENV !== 'production') {
|
|
233
|
-
_countSink.__pyreon_count__?.('reactivity.computedRecompute')
|
|
234
|
-
_rdRecordFire(read)
|
|
235
|
-
}
|
|
236
|
-
cleanupLocalDeps(deps, recompute)
|
|
237
|
-
try {
|
|
238
|
-
const next = trackWithLocalDeps(deps, recompute, fn)
|
|
239
|
-
if (initialized && equals(value as T, next)) return
|
|
240
|
-
value = next
|
|
241
|
-
dirty = false
|
|
242
|
-
initialized = true
|
|
243
|
-
} catch (err) {
|
|
244
|
-
_errorHandler(err)
|
|
245
|
-
return
|
|
246
|
-
}
|
|
247
|
-
if (host._s) notifySubscribers(host._s)
|
|
248
|
-
if (directFns) for (const f of directFns) f()
|
|
249
|
-
}
|
|
250
|
-
_markRecompute(recompute)
|
|
251
|
-
|
|
252
|
-
const read = (): T => {
|
|
253
|
-
trackSubscriber(host)
|
|
254
|
-
if (dirty) {
|
|
255
|
-
if (process.env.NODE_ENV !== 'production')
|
|
256
|
-
_countSink.__pyreon_count__?.('reactivity.computedRecompute')
|
|
257
|
-
cleanupLocalDeps(deps, recompute)
|
|
258
|
-
try {
|
|
259
|
-
value = trackWithLocalDeps(deps, recompute, fn)
|
|
260
|
-
} catch (err) {
|
|
261
|
-
_errorHandler(err)
|
|
262
|
-
}
|
|
263
|
-
dirty = false
|
|
264
|
-
initialized = true
|
|
265
|
-
}
|
|
266
|
-
return value as T
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
read.dispose = () => {
|
|
270
|
-
disposed = true
|
|
271
|
-
cleanupLocalDeps(deps, recompute)
|
|
272
|
-
cleanupEffect(recompute)
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
Object.defineProperty(read, '_v', {
|
|
276
|
-
get: () => {
|
|
277
|
-
if (dirty) read()
|
|
278
|
-
return value
|
|
279
|
-
},
|
|
280
|
-
enumerable: false,
|
|
281
|
-
})
|
|
282
|
-
|
|
283
|
-
// @internal — mirrors `signal._d`. Lets tests deterministically assert
|
|
284
|
-
// the live direct-updater set stays BOUNDED under register/dispose
|
|
285
|
-
// churn (the never-compacted-array leak this fix removes).
|
|
286
|
-
Object.defineProperty(read, '_d', {
|
|
287
|
-
get: () => directFns,
|
|
288
|
-
enumerable: false,
|
|
289
|
-
})
|
|
290
|
-
|
|
291
|
-
read.direct = (updater: () => void): (() => void) => {
|
|
292
|
-
if (!directFns) directFns = new Set()
|
|
293
|
-
const set = directFns
|
|
294
|
-
set.add(updater)
|
|
295
|
-
return () => {
|
|
296
|
-
set.delete(updater)
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
if (process.env.NODE_ENV !== 'production')
|
|
301
|
-
// skipFrames=2: skip computedLazy/computedWithEquals + computed, capture user's call site.
|
|
302
|
-
_rdRegister(
|
|
303
|
-
read,
|
|
304
|
-
'derived',
|
|
305
|
-
host,
|
|
306
|
-
recompute,
|
|
307
|
-
undefined,
|
|
308
|
-
injectedLoc ?? _captureCallerLocation(2),
|
|
309
|
-
)
|
|
310
|
-
|
|
311
|
-
getCurrentScope()?.add({ dispose: read.dispose })
|
|
312
|
-
return read as Computed<T>
|
|
313
|
-
}
|