fix(activity): release subscriptions when ActivityTracker.on is disposed - #151
Draft
scottmessinger wants to merge 1 commit into
Draft
fix(activity): release subscriptions when ActivityTracker.on is disposed#151scottmessinger wants to merge 1 commit into
scottmessinger wants to merge 1 commit into
Conversation
`on()` recorded a second, private reference to every subscription in addition to the unsubscribe function it returned. Calling that function removed the handler from the dispatch set but not the private reference, so a long-lived tracker accumulated one dead closure per subscription for its whole life, each pinning its handler and everything the handler closed over. A component that subscribes on mount and unsubscribes on unmount leaked one entry per mount. `destroy()` ran those closures but left the listener registry populated, so the handlers stayed reachable from the tracker afterwards too. Rather than keep the two records in sync, the private array is gone. It only ever existed so `destroy()` could drop every subscriber, and clearing the listener registry does that in one step — so `destroy()` now unsubscribes the chart, clears the registry, and stops the actor, with nothing accumulating in between. The chart's own three subscriptions were the array's other occupant; they move to a single field, since their lifetime is the tracker's. Tests cover retention directly, not just delivery: five of the nine fail against the previous implementation, including 500 subscribe/unsubscribe cycles leaving 500 dead closures behind, and `destroy()` leaving its subscribers reachable. No API change — `on()` returns the same unsubscribe function and behaves the same. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5ia3Qg9aFimZoMia8faQr
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #151 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 65 65
Lines 2321 2318 -3
Branches 571 571
=========================================
- Hits 2321 2318 -3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The leak
ActivityTracker.on()recorded a second, private reference to every subscription, in addition to the unsubscribe function it handed back:Calling the returned function removed the handler from the dispatch set but left that private entry in place. So a long-lived tracker accumulated one dead closure per subscription for its whole life, each pinning its handler and everything the handler closed over. A component that subscribes on mount and unsubscribes on unmount leaks one entry per mount.
destroy()ran those closures but never clearedlisteners, so the handlers stayed reachable from the tracker afterwards too.The fix
Rather than keep the two records in sync, the private array is gone.
It only ever existed so
destroy()could drop every subscriber — and clearing the listener registry does that in one step, so it was never needed.destroy()now unsubscribes the chart, clears the registry, and stops the actor, with nothing accumulating in between. The chart's own three subscriptions were the array's other occupant; they move to a single field, since their lifetime is the tracker's.Net effect: one field instead of an array of closures, the leak closed by construction rather than by remembering to remove entries, and
destroy()reads directly.No API change —
on()returns the same unsubscribe function and behaves the same.Tests
packages/activity/tests/subscriptions.test.tscovers retention, not just delivery — the behavioural half (an unsubscribed handler stops firing) was already true, so tests that only checked that would have passed against the bug.Five of the nine fail against the previous implementation:
destroy()releases every subscriberPlus:
destroy()delivers no further events and freezes the reactivestatus, is idempotent, and still detaches DOM listeners.Checks
Every command in
AGENTS.mdpasses:pnpm run format:check— cleanpnpm lint— 0 errorspnpm run build— all packagespnpm run typecheck— all packagespnpm test— 86 files, 1126 testspnpm run test:validate— 5 testspnpm coverage—activity-tracker.tsat 100%Includes a patch changeset for
@supergrain/activity.Found while property-testing the tracker in #149, and split out here since it's a behaviour fix rather than test coverage.
🤖 Generated with Claude Code
https://claude.ai/code/session_01K5ia3Qg9aFimZoMia8faQr
Generated by Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.