Severity: 🟡 Medium (memory leaks)
Several composables create long-lived reactive primitives with no disposal path, so they (and the timers/subscriptions they hold) leak whenever they are not created inside an active effectScope.
Locations & details
1. deferred() leaks its internal effect
src/concurrency/scheduling.ts:141-161 — deferred(source) creates an effect(() => …) subscribing to source and returns only readonly(mirror). There is no returned dispose and no onScopeDispose registration. Outside a scope, the effect (plus the scheduled timer/idle callback it manages) stays subscribed to source for the source's lifetime.
2. persistedSignal() persistence effect has no disposer
src/reactive/persisted.ts:63-71 — the persistence effect() returns no disposer. There is no way to stop persistence; and because effect() auto-registers with the ambient effectScope, calling persistedSignal() inside a scope means scope.stop() silently stops persistence while the signal keeps living.
3. Unscoped computed() retained by a long-lived signal
src/reactive/core.ts:23, src/reactive/computed.ts:137-142 — a Signal holds subscribers in a strong Set, and computed() only auto-disposes when created inside an active effectScope. A computed created at module/function scope, read once, depending on a long-lived signal, keeps its markDirty in the signal's subscriber set forever even after all user references drop.
Suggested fix
deferred(): return a dispose() (stop the effect + scheduled.cancel()), or register cleanup via onScopeDispose when a scope is active (mirroring usePolling/suspense).
persistedSignal(): isolate the persistence watcher from the ambient scope and/or return a dispose.
computed(): document the must-dispose contract prominently and/or provide auto-cleanup (FinalizationRegistry/WeakRef-based pruning), or have composables that internally create computeds aggregate their disposal.
Filed as part of a full-codebase security & correctness audit.
Severity: 🟡 Medium (memory leaks)
Several composables create long-lived reactive primitives with no disposal path, so they (and the timers/subscriptions they hold) leak whenever they are not created inside an active
effectScope.Locations & details
1.
deferred()leaks its internal effectsrc/concurrency/scheduling.ts:141-161—deferred(source)creates aneffect(() => …)subscribing tosourceand returns onlyreadonly(mirror). There is no returned dispose and noonScopeDisposeregistration. Outside a scope, the effect (plus the scheduled timer/idle callback it manages) stays subscribed tosourcefor the source's lifetime.2.
persistedSignal()persistence effect has no disposersrc/reactive/persisted.ts:63-71— the persistenceeffect()returns no disposer. There is no way to stop persistence; and becauseeffect()auto-registers with the ambienteffectScope, callingpersistedSignal()inside a scope meansscope.stop()silently stops persistence while the signal keeps living.3. Unscoped
computed()retained by a long-lived signalsrc/reactive/core.ts:23,src/reactive/computed.ts:137-142— aSignalholds subscribers in a strongSet, andcomputed()only auto-disposes when created inside an activeeffectScope. Acomputedcreated at module/function scope, read once, depending on a long-lived signal, keeps itsmarkDirtyin the signal's subscriber set forever even after all user references drop.Suggested fix
deferred(): return adispose()(stop the effect +scheduled.cancel()), or register cleanup viaonScopeDisposewhen a scope is active (mirroringusePolling/suspense).persistedSignal(): isolate the persistence watcher from the ambient scope and/or return adispose.computed(): document the must-dispose contract prominently and/or provide auto-cleanup (FinalizationRegistry/WeakRef-based pruning), or have composables that internally create computeds aggregate their disposal.Filed as part of a full-codebase security & correctness audit.