subscription/index.ts L89-92: disposers and subscriptions Maps not cleared on last-subscriber teardown
When the last subscriber unmounts, the cleanup callback calls dispose() but never removes the stale entries from the subscriptions and disposers Maps. In a long-lived app that cycles through many distinct subscription keys (e.g. paginated feeds, rotating live-data channels), both Maps accumulate one dead entry per unique key and grow without bound.
Affected code
File: src/subscription/index.ts, lines 84-93
return () => {
const count = subscriptions.get(subscriptionKey)! - 1
subscriptions.set(subscriptionKey, count)
// Dispose if it's the last one.
if (!count) {
const dispose = disposers.get(subscriptionKey)
dispose?.()
// <-- disposers and subscriptions entries are never deleted here
}
}
Why this is a bug
subscriptions (ref-count Map) and disposers (cleanup function Map) are allocated once per cache boundary and live for the lifetime of the cache object. Every key that ever passes through useSubscription leaves a permanent entry after its last subscriber unmounts:
subscriptions keeps the key mapped to 0.
disposers keeps the key mapped to the (now-called) dispose function, preventing the function object from being garbage-collected.
In applications with a large or unbounded set of subscription keys (infinite scroll, user-navigated channels, rotating market tickers, etc.) this is a straightforward memory leak. The leak is proportional to the number of distinct keys ever used, not the number of currently-active subscriptions.
A secondary issue: because the zero-count entry remains in subscriptions, a future remount of the same key reads refCount = 0 correctly (the || 0 fallback), so the functional behaviour is not broken -- only the memory footprint grows.
Repro scenario
// Rotate through N distinct keys over time.
// After unmounting each key the Maps retain stale entries.
function RotatingSubscription({ channel }: { channel: string }) {
useSWRSubscription(channel, (key, { next }) => {
const ws = new WebSocket(`wss://example.com/${key}`)
ws.onmessage = (e) => next(null, e.data)
return () => ws.close()
})
return null
}
// Mount/unmount with channel = "ch-0", "ch-1", ..., "ch-9999"
// After all unmounts: subscriptions.size === 10000, disposers.size === 10000
Suggested fix
Delete the entries from both Maps immediately after calling dispose():
if (!count) {
const dispose = disposers.get(subscriptionKey)
dispose?.()
disposers.delete(subscriptionKey) // <-- add
subscriptions.delete(subscriptionKey) // <-- add
}
This restores the Maps to a size proportional to the number of currently active subscription keys rather than all keys ever seen.
Reporter: Nexory
subscription/index.ts L89-92: disposers and subscriptions Maps not cleared on last-subscriber teardown
When the last subscriber unmounts, the cleanup callback calls
dispose()but never removes the stale entries from thesubscriptionsanddisposersMaps. In a long-lived app that cycles through many distinct subscription keys (e.g. paginated feeds, rotating live-data channels), both Maps accumulate one dead entry per unique key and grow without bound.Affected code
File:
src/subscription/index.ts, lines 84-93Why this is a bug
subscriptions(ref-count Map) anddisposers(cleanup function Map) are allocated once per cache boundary and live for the lifetime of the cache object. Every key that ever passes throughuseSubscriptionleaves a permanent entry after its last subscriber unmounts:subscriptionskeeps the key mapped to0.disposerskeeps the key mapped to the (now-called) dispose function, preventing the function object from being garbage-collected.In applications with a large or unbounded set of subscription keys (infinite scroll, user-navigated channels, rotating market tickers, etc.) this is a straightforward memory leak. The leak is proportional to the number of distinct keys ever used, not the number of currently-active subscriptions.
A secondary issue: because the zero-count entry remains in
subscriptions, a future remount of the same key readsrefCount = 0correctly (the|| 0fallback), so the functional behaviour is not broken -- only the memory footprint grows.Repro scenario
Suggested fix
Delete the entries from both Maps immediately after calling
dispose():This restores the Maps to a size proportional to the number of currently active subscription keys rather than all keys ever seen.
Reporter: Nexory