@pyreon/reactivity 0.14.0 → 0.16.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.
@@ -71,6 +71,19 @@ interface ComputedOptions<T> {
71
71
  declare function computed<T>(fn: () => T, options?: ComputedOptions<T>): Computed<T>;
72
72
  //#endregion
73
73
  //#region src/createSelector.d.ts
74
+ /** Selector predicate with a `dispose()` method to release internal state. */
75
+ interface Selector<T> {
76
+ (value: T): boolean;
77
+ /**
78
+ * Stop the source-tracking effect AND clear the per-value subscriber/host
79
+ * Maps. After dispose, calls to the selector return the last-known result
80
+ * but no longer track. Required for selectors over dynamic value spaces
81
+ * (UUIDs, ephemeral IDs) created outside an `EffectScope` — without it,
82
+ * each unique queried value adds a permanent entry to the internal Maps,
83
+ * leaking memory for the lifetime of the program. Idempotent.
84
+ */
85
+ dispose(): void;
86
+ }
74
87
  /**
75
88
  * Create an equality selector — returns a reactive predicate that is true
76
89
  * only for the currently selected value.
@@ -83,8 +96,13 @@ declare function computed<T>(fn: () => T, options?: ComputedOptions<T>): Compute
83
96
  * const isSelected = createSelector(selectedId)
84
97
  * // In each row:
85
98
  * class: () => (isSelected(row.id) ? "selected" : "")
99
+ *
100
+ * @example
101
+ * // Dynamic value spaces — call dispose() to release the per-value cache:
102
+ * const isCurrentTab = createSelector(() => currentTabId())
103
+ * onUnmount(() => isCurrentTab.dispose())
86
104
  */
87
- declare function createSelector<T>(source: () => T): (value: T) => boolean;
105
+ declare function createSelector<T>(source: () => T): Selector<T>;
88
106
  //#endregion
89
107
  //#region src/signal.d.ts
90
108
  interface SignalDebugInfo<T> {
@@ -120,7 +138,11 @@ interface Signal<T> {
120
138
  * Returns a disposer that nulls the slot.
121
139
  */
122
140
  direct(updater: () => void): () => void;
123
- /** Debug name — useful for devtools and logging. */
141
+ /**
142
+ * Debug name — useful for devtools and logging. Set via the `name` option at
143
+ * creation; can be reassigned at any time (`s.label = 'renamed'`) since it's
144
+ * stored as a regular own property on the signal function.
145
+ */
124
146
  label: string | undefined;
125
147
  /** Returns a snapshot of the signal's debug info (value, name, subscriber count). */
126
148
  debug(): SignalDebugInfo<T>;
@@ -191,6 +213,19 @@ declare function inspectSignal<T>(sig: Signal<T>): SignalDebugInfo<T>;
191
213
  interface Effect {
192
214
  dispose(): void;
193
215
  }
216
+ interface ReactiveSnapshotCapture {
217
+ capture: () => unknown;
218
+ /** Run `fn` with the previously-captured snapshot active. */
219
+ restore: <T>(snap: unknown, fn: () => T) => T;
220
+ }
221
+ /**
222
+ * Register a capture/restore pair so reactivity-layer effects (`_bind`,
223
+ * `renderEffect`, `effect`) can preserve external context (e.g. the core
224
+ * provide/useContext stack) across signal-driven re-runs. Called by
225
+ * `@pyreon/core`'s context module at import time. Idempotent — calling again
226
+ * replaces the previously registered hook.
227
+ */
228
+ declare function setSnapshotCapture(hook: ReactiveSnapshotCapture | null): void;
194
229
  /**
195
230
  * Register a cleanup function inside an effect. The cleanup runs:
196
231
  * - Before the effect re-runs (when dependencies change)
@@ -273,6 +308,13 @@ interface Resource<T> {
273
308
  error: Signal<unknown>;
274
309
  /** Re-run the fetcher with the current source value. */
275
310
  refetch(): void;
311
+ /**
312
+ * Stop the source-tracking effect. After dispose(), source changes no
313
+ * longer trigger fetches and any in-flight response is ignored. Idempotent.
314
+ * Required for resources created outside an `EffectScope` to avoid leaking
315
+ * the source-tracking effect for the lifetime of the program.
316
+ */
317
+ dispose(): void;
276
318
  }
277
319
  /**
278
320
  * Async data primitive. Fetches data reactively whenever `source()` changes.
@@ -317,6 +359,24 @@ declare function getCurrentScope(): EffectScope | null;
317
359
  declare function setCurrentScope(scope: EffectScope | null): void;
318
360
  /** Create a new EffectScope. */
319
361
  declare function effectScope(): EffectScope;
362
+ /**
363
+ * Register a callback to run when the current `EffectScope` stops. Vue 3
364
+ * parity. Must be called inside `scope.runInScope(fn)` — the registration
365
+ * captures the ambient scope, so calling outside any scope is a no-op (with
366
+ * a dev warning to surface the missing scope).
367
+ *
368
+ * Use to clean up resources tied to a scope's lifetime: timers, listeners,
369
+ * external subscriptions. Equivalent to calling `getCurrentScope()?.add({
370
+ * dispose: fn })` but with the scope capture handled.
371
+ *
372
+ * @example
373
+ * scope.runInScope(() => {
374
+ * const ws = new WebSocket(url)
375
+ * onScopeDispose(() => ws.close())
376
+ * // ws.close() runs when scope.stop() is called
377
+ * })
378
+ */
379
+ declare function onScopeDispose(fn: () => void): void;
320
380
  //#endregion
321
381
  //#region src/store.d.ts
322
382
  /**
@@ -333,6 +393,20 @@ declare function effectScope(): EffectScope;
333
393
  * state.count++ // only the count effect re-runs
334
394
  * state.items[0].text = "world" // only text-tracking effects re-run
335
395
  */
396
+ /**
397
+ * Mark an object as RAW — `createStore` and `shallowReactive` will return it
398
+ * unwrapped. Useful when storing class instances, third-party objects, or
399
+ * other shapes that shouldn't be deeply proxied (Vue 3 parity).
400
+ *
401
+ * @example
402
+ * const cm = markRaw(new CodeMirrorView(...))
403
+ * const store = createStore({ editor: cm })
404
+ * store.editor === cm // true (not wrapped)
405
+ *
406
+ * Note: marking is one-way — there's no `unmarkRaw`. Mark BEFORE the object
407
+ * enters a store; marking after wrap doesn't unwrap an existing proxy.
408
+ */
409
+ declare function markRaw<T extends object>(value: T): T;
336
410
  /** Returns true if the value is a createStore proxy. */
337
411
  declare function isStore(value: unknown): boolean;
338
412
  /**
@@ -340,6 +414,24 @@ declare function isStore(value: unknown): boolean;
340
414
  * Returns a proxy — mutations to the proxy trigger fine-grained reactive updates.
341
415
  */
342
416
  declare function createStore<T extends object>(initial: T): T;
417
+ /**
418
+ * Create a SHALLOW reactive store — only top-level mutations trigger updates.
419
+ * Nested objects are NOT auto-wrapped; reading a nested object returns the
420
+ * raw reference. Use when:
421
+ * - the nested objects are immutable (frozen API responses)
422
+ * - you want explicit control over which subtrees are reactive
423
+ * - you need to store class instances or third-party objects without
424
+ * paying the deep-proxy overhead
425
+ *
426
+ * @example
427
+ * const store = shallowReactive({ user: { name: 'Alice' }, count: 0 })
428
+ * effect(() => console.log(store.user)) // tracks store.user reference
429
+ * effect(() => console.log(store.count)) // tracks store.count
430
+ * store.user.name = 'Bob' // does NOT trigger any effect
431
+ * store.count = 5 // triggers the count effect
432
+ * store.user = { name: 'Bob' } // triggers the user effect
433
+ */
434
+ declare function shallowReactive<T extends object>(initial: T): T;
343
435
  //#endregion
344
436
  //#region src/tracking.d.ts
345
437
  /** Read signals without subscribing. Alias: `untrack`. */
@@ -373,5 +465,5 @@ interface WatchOptions {
373
465
  */
374
466
  declare function watch<T>(source: () => T, callback: (newVal: T, oldVal: T | undefined) => void | (() => void), opts?: WatchOptions): () => void;
375
467
  //#endregion
376
- export { Cell, type Computed, type ComputedOptions, type Effect, EffectScope, type ReadonlySignal, type Resource, type Signal, type SignalDebugInfo, type SignalOptions, type WatchOptions, _bind, batch, cell, computed, createResource, createSelector, createStore, effect, effectScope, getCurrentScope, inspectSignal, isStore, nextTick, onCleanup, onSignalUpdate, reconcile, renderEffect, runUntracked, runUntracked as untrack, setCurrentScope, setErrorHandler, signal, watch, why };
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 };
377
469
  //# sourceMappingURL=index2.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/reactivity",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
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": {
@@ -14,6 +14,7 @@
14
14
  },
15
15
  "files": [
16
16
  "lib",
17
+ "!lib/**/*.map",
17
18
  "src",
18
19
  "README.md",
19
20
  "LICENSE"
@@ -33,9 +34,6 @@
33
34
  "publishConfig": {
34
35
  "access": "public"
35
36
  },
36
- "devDependencies": {
37
- "@pyreon/manifest": "0.13.1"
38
- },
39
37
  "scripts": {
40
38
  "build": "vl_rolldown_build",
41
39
  "dev": "vl_rolldown_build-watch",
@@ -43,5 +41,8 @@
43
41
  "typecheck": "tsc --noEmit",
44
42
  "lint": "oxlint .",
45
43
  "prepublishOnly": "bun run build"
44
+ },
45
+ "devDependencies": {
46
+ "@pyreon/manifest": "0.13.1"
46
47
  }
47
48
  }
package/src/batch.ts CHANGED
@@ -1,15 +1,66 @@
1
1
  // Batch multiple signal updates into a single notification pass.
2
- // Uses a Set so the same subscriber is never flushed more than once per batch,
3
- // even if multiple signals it depends on change within the same batch.
2
+ // Two-tier flush: computed recomputes drain first (within-pass Set-dedup),
3
+ // THEN effects drain (multi-pass with cross-pass re-fire support).
4
+ //
5
+ // Dev-mode invariant gate: see https://github.com/pyreon/pyreon/blob/main/packages/core/reactivity/src/tests/batch.test.ts
6
+ // for the property-based test that fuzzes random cascade graphs against this
7
+ // invariant. The build-time gate folds to dead code in production bundles.
8
+ const __DEV__ = process.env.NODE_ENV !== 'production'
4
9
 
5
10
  let batchDepth = 0
6
11
 
7
- // Two pre-allocated Sets swapped on each flush — avoids allocating a new Set()
8
- // on every batch exit. The "active" set collects enqueued notifications; on flush
9
- // we swap to the other set and iterate the captured one, then clear it for reuse.
10
- const setA = new Set<() => void>()
11
- const setB = new Set<() => void>()
12
- let pendingNotifications = setA
12
+ // Two-tier queue design:
13
+ //
14
+ // 1. **`pendingRecomputes`** computed.recompute callbacks. Drained FIRST in
15
+ // a cascading-iteration loop: cascading recomputes enqueued during a
16
+ // drain land in the same Set (Set.add idempotency dedupes), iteration
17
+ // visits added entries (JS Set iteration semantics), and the recompute
18
+ // layer settles before any effect fires. This guarantees effects always
19
+ // read fully-propagated computed values.
20
+ //
21
+ // 2. **`pendingEffects`** — effect.run callbacks. Drained SECOND, multi-pass.
22
+ // Within a single pass, Set.add idempotency gives us within-pass dedup
23
+ // (diamond / multi-dep selector). Across passes (entries re-enqueued
24
+ // AFTER being visited in the current pass — see `_visitedThisPass`), they
25
+ // fire AGAIN — needed for control flow that re-renders based on its own
26
+ // dispatch (e.g. ErrorBoundary's handler calling `error.set(err)` during
27
+ // the same run that mounted the throwing child). MAX_PASSES caps total
28
+ // passes at 32 to prevent pathological infinite re-enqueue loops.
29
+ //
30
+ // **Why two tiers, not one Set:**
31
+ // - Single-Set iteration (the prior design) worked for shallow cascades
32
+ // because the cascading recompute → effect re-enqueue happened BEFORE
33
+ // iteration reached the effect. For deep cascades (3+ hops), iteration
34
+ // reached the effect (with its stale upstream value) BEFORE the cascade
35
+ // finished propagating. Effect read stale values; subsequent cascade
36
+ // re-enqueues were dropped by Set.add idempotency.
37
+ // - Splitting recomputes from effects fixes this: all computed recomputes
38
+ // settle before any effect runs. The cascade-asymmetry contract from
39
+ // PR #381 is preserved (effects still fire once per batched change),
40
+ // AND deep-cascade correctness is added (effects always read settled
41
+ // values).
42
+ // - Multi-pass effect drain unblocks ErrorBoundary's "re-fire after
43
+ // dispatching from inside own run" pattern without breaking the
44
+ // single-fire contract for non-self-dispatching effects.
45
+ //
46
+ // **How a callback gets routed:** computed registers its `recompute` via
47
+ // `_markRecompute(fn)` at creation time. The internal `_recomputes` WeakSet
48
+ // tracks them. `enqueuePendingNotification` checks the WeakSet to route.
49
+ const pendingRecomputes = new Set<() => void>()
50
+ const pendingEffects = new Set<() => void>()
51
+ const _nextEffectPass = new Set<() => void>()
52
+ let _visitedThisPass: Set<() => void> | null = null
53
+ const _recomputes = new WeakSet<() => void>()
54
+ const MAX_PASSES = 32
55
+
56
+ /**
57
+ * Mark a callback as a computed recompute (called from computed.ts at
58
+ * creation time). Routes future enqueues into the recompute queue so they
59
+ * settle before any effects fire.
60
+ */
61
+ export function _markRecompute(fn: () => void): void {
62
+ _recomputes.add(fn)
63
+ }
13
64
 
14
65
  export function batch(fn: () => void): void {
15
66
  batchDepth++
@@ -17,34 +68,92 @@ export function batch(fn: () => void): void {
17
68
  fn()
18
69
  } finally {
19
70
  batchDepth--
20
- if (batchDepth === 0 && pendingNotifications.size > 0) {
71
+ if (batchDepth === 0 && (pendingRecomputes.size > 0 || pendingEffects.size > 0)) {
21
72
  // Keep batching active during flush so cascade-notifications emitted
22
- // by flushing subscribers enqueue into the pending Set (and dedupe
23
- // against what's already queued) instead of firing inline. The
24
- // while-loop drains any cascade rounds until the graph is stable.
25
- //
26
- // Without this, a diamond dependency (a → b, c → d → effect) re-fires
27
- // the apex effect TWICE per signal write: the first notification path
28
- // through `b` reaches `effect`, whose read clears `d`'s dirty flag;
29
- // then when `c` is notified (still in the first flush round), it
30
- // re-dirties `d`, which re-notifies `effect`. Keeping batchDepth at 1
31
- // during flush routes those cascade-notifications through the Set,
32
- // which dedupes on `d`'s recompute and on `effect`'s run.
33
- //
34
- // See `packages/internals/perf-harness/src/tests/diamond-probe.test.ts`
35
- // for the empirical probe that caught this.
73
+ // by flushing subscribers enqueue into the same queues (dedup against
74
+ // already-queued entries) instead of firing inline.
36
75
  batchDepth = 1
37
76
  try {
38
- while (pendingNotifications.size > 0) {
39
- // Swap to the other pre-allocated Set before flushing so new
40
- // enqueues during notification land in the alternate Set, not
41
- // mixed into the current iteration.
42
- const flush = pendingNotifications
43
- pendingNotifications = flush === setA ? setB : setA
44
- for (const notify of flush) notify()
45
- flush.clear()
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
+ }
46
144
  }
47
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
48
157
  batchDepth = 0
49
158
  }
50
159
  }
@@ -55,8 +164,22 @@ export function isBatching(): boolean {
55
164
  return batchDepth > 0
56
165
  }
57
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
+
58
172
  export function enqueuePendingNotification(notify: () => void): void {
59
- pendingNotifications.add(notify)
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
+ }
60
183
  }
61
184
 
62
185
  /**
package/src/computed.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { _markRecompute } from './batch'
1
2
  import { _errorHandler } from './effect'
2
3
  import { getCurrentScope } from './scope'
3
4
  import {
@@ -10,9 +11,6 @@ import {
10
11
  } from './tracking'
11
12
 
12
13
  // Dev-time counter sink — see packages/internals/perf-harness for contract.
13
- interface ViteMeta {
14
- readonly env?: { readonly DEV?: boolean }
15
- }
16
14
  const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
17
15
 
18
16
  export interface Computed<T> {
@@ -54,6 +52,21 @@ function trackWithLocalDeps<T>(deps: Set<() => void>[], effect: () => void, fn:
54
52
  }
55
53
 
56
54
  export function computed<T>(fn: () => T, options?: ComputedOptions<T>): Computed<T> {
55
+ // Dev warning for async computed callbacks (audit bug #1 — extension).
56
+ // `computed(async () => …)` returns `Computed<Promise<T>>`, which silently
57
+ // breaks every consumer that expects `Computed<T>`. There's no scenario
58
+ // where async makes sense here — the recompute fires synchronously and
59
+ // tracks signals only in the synchronous prefix. For async-derived
60
+ // state, use `createResource` or a `signal<T>` updated from an effect.
61
+ if (process.env.NODE_ENV !== 'production') {
62
+ if (fn.constructor && fn.constructor.name === 'AsyncFunction') {
63
+ // oxlint-disable-next-line no-console
64
+ console.warn(
65
+ '[pyreon] computed() received an async function. The result type becomes `Computed<Promise<T>>`, and signal reads after the first `await` are NOT tracked. ' +
66
+ 'Use `createResource` for async-derived state, or compute synchronously over a signal that holds the awaited value.',
67
+ )
68
+ }
69
+ }
57
70
  return options?.equals ? computedWithEquals(fn, options.equals) : computedLazy(fn)
58
71
  }
59
72
 
@@ -82,11 +95,12 @@ function computedLazy<T>(fn: () => T): Computed<T> {
82
95
  if (host._s) notifySubscribers(host._s)
83
96
  if (directFns) for (const f of directFns) f?.()
84
97
  }
98
+ _markRecompute(recompute)
85
99
 
86
100
  const read = (): T => {
87
101
  trackSubscriber(host)
88
102
  if (dirty) {
89
- if ((import.meta as ViteMeta).env?.DEV === true)
103
+ if (process.env.NODE_ENV !== 'production')
90
104
  _countSink.__pyreon_count__?.('reactivity.computedRecompute')
91
105
  try {
92
106
  if (tracked) {
@@ -153,7 +167,7 @@ function computedWithEquals<T>(fn: () => T, equals: (prev: T, next: T) => boolea
153
167
 
154
168
  const recompute = () => {
155
169
  if (disposed) return
156
- if ((import.meta as ViteMeta).env?.DEV === true)
170
+ if (process.env.NODE_ENV !== 'production')
157
171
  _countSink.__pyreon_count__?.('reactivity.computedRecompute')
158
172
  cleanupLocalDeps(deps, recompute)
159
173
  try {
@@ -169,11 +183,12 @@ function computedWithEquals<T>(fn: () => T, equals: (prev: T, next: T) => boolea
169
183
  if (host._s) notifySubscribers(host._s)
170
184
  if (directFns) for (const f of directFns) f?.()
171
185
  }
186
+ _markRecompute(recompute)
172
187
 
173
188
  const read = (): T => {
174
189
  trackSubscriber(host)
175
190
  if (dirty) {
176
- if ((import.meta as ViteMeta).env?.DEV === true)
191
+ if (process.env.NODE_ENV !== 'production')
177
192
  _countSink.__pyreon_count__?.('reactivity.computedRecompute')
178
193
  cleanupLocalDeps(deps, recompute)
179
194
  try {
@@ -21,6 +21,20 @@ function notifyBucket(bucket: Set<() => void>): void {
21
21
  }
22
22
  }
23
23
 
24
+ /** Selector predicate with a `dispose()` method to release internal state. */
25
+ export interface Selector<T> {
26
+ (value: T): boolean
27
+ /**
28
+ * Stop the source-tracking effect AND clear the per-value subscriber/host
29
+ * Maps. After dispose, calls to the selector return the last-known result
30
+ * but no longer track. Required for selectors over dynamic value spaces
31
+ * (UUIDs, ephemeral IDs) created outside an `EffectScope` — without it,
32
+ * each unique queried value adds a permanent entry to the internal Maps,
33
+ * leaking memory for the lifetime of the program. Idempotent.
34
+ */
35
+ dispose(): void
36
+ }
37
+
24
38
  /**
25
39
  * Create an equality selector — returns a reactive predicate that is true
26
40
  * only for the currently selected value.
@@ -33,13 +47,19 @@ function notifyBucket(bucket: Set<() => void>): void {
33
47
  * const isSelected = createSelector(selectedId)
34
48
  * // In each row:
35
49
  * class: () => (isSelected(row.id) ? "selected" : "")
50
+ *
51
+ * @example
52
+ * // Dynamic value spaces — call dispose() to release the per-value cache:
53
+ * const isCurrentTab = createSelector(() => currentTabId())
54
+ * onUnmount(() => isCurrentTab.dispose())
36
55
  */
37
- export function createSelector<T>(source: () => T): (value: T) => boolean {
56
+ export function createSelector<T>(source: () => T): Selector<T> {
38
57
  const subs = new Map<T, Set<() => void>>()
39
58
  let current: T
40
59
  let initialized = false
60
+ let disposed = false
41
61
 
42
- effect(() => {
62
+ const sourceEffect = effect(() => {
43
63
  const next = source()
44
64
  if (!initialized) {
45
65
  initialized = true
@@ -60,18 +80,30 @@ export function createSelector<T>(source: () => T): (value: T) => boolean {
60
80
  // Reusable hosts per value — avoids allocating a closure per trackSubscriber call
61
81
  const hosts = new Map<T, { _s: Set<() => void> | null }>()
62
82
 
63
- return (value: T): boolean => {
64
- let host = hosts.get(value)
65
- if (!host) {
66
- let bucket = subs.get(value)
67
- if (!bucket) {
68
- bucket = new Set()
69
- subs.set(value, bucket)
83
+ const selector = ((value: T): boolean => {
84
+ if (!disposed) {
85
+ let host = hosts.get(value)
86
+ if (!host) {
87
+ let bucket = subs.get(value)
88
+ if (!bucket) {
89
+ bucket = new Set()
90
+ subs.set(value, bucket)
91
+ }
92
+ host = { _s: bucket }
93
+ hosts.set(value, host)
70
94
  }
71
- host = { _s: bucket }
72
- hosts.set(value, host)
95
+ trackSubscriber(host)
73
96
  }
74
- trackSubscriber(host)
75
97
  return Object.is(current, value)
98
+ }) as Selector<T>
99
+
100
+ selector.dispose = (): void => {
101
+ if (disposed) return
102
+ disposed = true
103
+ sourceEffect.dispose()
104
+ subs.clear()
105
+ hosts.clear()
76
106
  }
107
+
108
+ return selector
77
109
  }