@pyreon/reactivity 0.24.5 → 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.
Files changed (44) hide show
  1. package/package.json +1 -4
  2. package/src/batch.ts +0 -196
  3. package/src/cell.ts +0 -72
  4. package/src/computed.ts +0 -313
  5. package/src/createSelector.ts +0 -109
  6. package/src/debug.ts +0 -134
  7. package/src/effect.ts +0 -467
  8. package/src/env.d.ts +0 -6
  9. package/src/index.ts +0 -60
  10. package/src/lpih.ts +0 -227
  11. package/src/manifest.ts +0 -660
  12. package/src/reactive-devtools.ts +0 -494
  13. package/src/reactive-trace.ts +0 -142
  14. package/src/reconcile.ts +0 -118
  15. package/src/resource.ts +0 -84
  16. package/src/scope.ts +0 -123
  17. package/src/signal.ts +0 -261
  18. package/src/store.ts +0 -250
  19. package/src/tests/batch.test.ts +0 -751
  20. package/src/tests/bind.test.ts +0 -84
  21. package/src/tests/branches.test.ts +0 -343
  22. package/src/tests/cell.test.ts +0 -159
  23. package/src/tests/computed.test.ts +0 -436
  24. package/src/tests/coverage-hardening.test.ts +0 -471
  25. package/src/tests/createSelector.test.ts +0 -291
  26. package/src/tests/debug.test.ts +0 -196
  27. package/src/tests/effect.test.ts +0 -464
  28. package/src/tests/fanout-repro.test.ts +0 -179
  29. package/src/tests/lpih-source-location.test.ts +0 -277
  30. package/src/tests/lpih.test.ts +0 -351
  31. package/src/tests/manifest-snapshot.test.ts +0 -96
  32. package/src/tests/reactive-devtools-treeshake.test.ts +0 -48
  33. package/src/tests/reactive-devtools.test.ts +0 -296
  34. package/src/tests/reactive-trace.test.ts +0 -102
  35. package/src/tests/reconcile-security.test.ts +0 -45
  36. package/src/tests/resource.test.ts +0 -326
  37. package/src/tests/scope.test.ts +0 -231
  38. package/src/tests/signal.test.ts +0 -368
  39. package/src/tests/store.test.ts +0 -286
  40. package/src/tests/tracking.test.ts +0 -158
  41. package/src/tests/vue-parity.test.ts +0 -191
  42. package/src/tests/watch.test.ts +0 -246
  43. package/src/tracking.ts +0 -139
  44. 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.5",
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
- }