Skip to content

fix(activity): release subscriptions when ActivityTracker.on is disposed - #151

Draft
scottmessinger wants to merge 1 commit into
mainfrom
claude/activity-tracker-listener-leak
Draft

fix(activity): release subscriptions when ActivityTracker.on is disposed#151
scottmessinger wants to merge 1 commit into
mainfrom
claude/activity-tracker-listener-leak

Conversation

@scottmessinger

@scottmessinger scottmessinger commented Aug 29, 2026

Copy link
Copy Markdown
Member

The leak

ActivityTracker.on() recorded a second, private reference to every subscription, in addition to the unsubscribe function it handed back:

const detach = () => { set.delete(handler); };
this.detachers.push(detach);   // never removed
return detach;

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 cleared listeners, 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.ts covers 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:

  • disposing one subscription leaves nothing retained
  • 500 subscribe/unsubscribe cycles leave nothing retained (previously: 500 dead closures)
  • live subscriptions survive while disposed ones are dropped
  • disposing twice is harmless and doesn't disturb siblings
  • destroy() releases every subscriber

Plus: destroy() delivers no further events and freezes the reactive status, is idempotent, and still detaches DOM listeners.

Checks

Every command in AGENTS.md passes:

  • pnpm run format:check — clean
  • pnpm lint — 0 errors
  • pnpm run build — all packages
  • pnpm run typecheck — all packages
  • pnpm test — 86 files, 1126 tests
  • pnpm run test:validate — 5 tests
  • pnpm coverageactivity-tracker.ts at 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


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

`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

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (bae5853) to head (d30109d).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants