Repository: ably/ably-chat-js
Pull request #518: Add unique listeners so unsubscribe() and stop extending EventEmitter
Author: login: vladvelici
Created: 2025-04-03T08:19:58Z | Merged: 2025-04-04T08:38:14Z

Stop extending EventEmitter so we get a cleaner public API (in JS).

Wrap all listeners into a unique reference function to make sure that subscribing the same function twice and unsubscribing one subscription will only unsubscribes that one subscription and not both.

### Context

* [CHA-856]
* [CHA-506]

### Checklist

* [x] QA'd by the author.
* [ ] Unit tests created (if applicable).
* [ ] Integration tests created (if applicable).
* [x] Follow coding style guidelines found [here](https://github.com/ably/engineering/tree/main/best-practices).
* [ ] TypeDoc updated (if applicable).
* [ ] Updated the [website documentation](https://github.com/ably/docs) (if applicable).
* [ ] Browser tests created (if applicable).
* [ ] In repo demo app updated (if applicable).

[CHA-856]: https://ably.atlassian.net/browse/CHA-856?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
[CHA-506]: https://ably.atlassian.net/browse/CHA-506?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ


Diff:
diff --git a/src/core/connection.ts b/src/core/connection.ts
index 1866d68ae..b077fc0f7 100644
--- a/src/core/connection.ts
+++ b/src/core/connection.ts
@@ -2,7 +2,7 @@ import * as Ably from 'ably';
 
 import { Logger } from './logger.js';
 import { StatusSubscription } from './subscription.js';
-import EventEmitter from './utils/event-emitter.js';
+import EventEmitter, { wrap } from './utils/event-emitter.js';
 
 /**
  * Default timeout for transient states before we attempt to handle them as a state change.
@@ -109,12 +109,13 @@ type ConnectionEventsMap = Record<ConnectionStatus, ConnectionStatusChange>;
  * An implementation of the `Connection` interface.
  * @internal
  */
-export class DefaultConnection extends EventEmitter<ConnectionEventsMap> implements Connection {
+export class DefaultConnection implements Connection {
   private _status: ConnectionStatus = ConnectionStatus.Initialized;
   private _error?: Ably.ErrorInfo;
   private readonly _connection: Ably.Connection;
   private readonly _logger: Logger;
   private _transientTimeout?: ReturnType<typeof setTimeout>;
+  private _emitter = new EventEmitter<ConnectionEventsMap>();
 
   /**
    * Constructs a new `DefaultConnection` instance.
@@ -122,7 +123,6 @@ export class DefaultConnection extends EventEmitter<ConnectionEventsMap> impleme
    * @param logger The logger to use.
    */
   constructor(ably: Ably.Realtime, logger: Logger) {
-    super();
     this._logger = logger;
 
     // Set our initial status and error
@@ -186,11 +186,12 @@ export class DefaultConnection extends EventEmitter<ConnectionEventsMap> impleme
    * @inheritdoc
    */
   onStatusChange(listener: ConnectionStatusListener): StatusSubscription {
-    this.on(listener);
+    const wrapped = wrap(listener);
+    this._emitter.on(wrapped);
 
     return {
       off: () => {
-        this.off(listener);
+        this._emitter.off(wrapped);
       },
     };
   }
@@ -199,14 +200,14 @@ export class DefaultConnection extends EventEmitter<ConnectionEventsMap> impleme
    * @inheritdoc
    */
   offAllStatusChange(): void {
-    this.off();
+    this._emitter.off();
   }
 
   private _applyStatusChange(change: ConnectionStatusChange): void {
     this._status = change.current;
     this._error = change.error;
     this._logger.info(`Connection state changed`, change);
-    this.emit(change.current, change);
+    this._emitter.emit(change.current, change);
   }
 
   private _mapAblyStatusToChat(status: Ably.ConnectionState): ConnectionStatus {
diff --git a/src/core/messages.ts b/src/core/messages.ts
index ff42f211b..3d609c15a 100644
--- a/src/core/messages.ts
+++ b/src/core/messages.ts
@@ -4,7 +4,6 @@ import { messagesChannelName } from './channel.js';
 import { ChannelManager } from './channel-manager.js';
 import { ChatApi } from './chat-api.js';
 import {
-  DiscontinuityEmitter,
   DiscontinuityListener,
   EmitsDiscontinuities,
   HandlesDiscontinuity,
@@ -19,7 +18,7 @@ import { parseMessage } from './message-parser.js';
 import { PaginatedResult } from './query.js';
 import { ContributesToRoomLifecycle } from './room-lifecycle-manager.js';
 import { Subscription } from './subscription.js';
-import EventEmitter from './utils/event-emitter.js';
+import EventEmitter, { wrap } from './utils/event-emitter.js';
 
 /**
  * Event names and their respective payloads emitted by the messages feature.
@@ -263,10 +262,7 @@ export interface Messages extends EmitsDiscontinuities {
 /**
  * @inheritDoc
  */
-export class DefaultMessages
-  extends EventEmitter<MessageEventsMap>
-  implements Messages, HandlesDiscontinuity, ContributesToRoomLifecycle
-{
+export class DefaultMessages implements Messages, HandlesDiscontinuity, ContributesToRoomLifecycle {
   private readonly _roomId: string;
   private readonly _channel: Ably.RealtimeChannel;
   private readonly _chatApi: ChatApi;
@@ -278,7 +274,8 @@ export class DefaultMessages
     }>
   >;
   private readonly _logger: Logger;
-  private readonly _discontinuityEmitter: DiscontinuityEmitter = newDiscontinuityEmitter();
+  private readonly _discontinuityEmitter = newDiscontinuityEmitter();
+  private readonly _emitter = new EventEmitter<MessageEventsMap>();
 
   /**
    * Constructs a new `DefaultMessages` instance.
@@ -289,7 +286,6 @@ export class DefaultMessages
    * @param logger An instance of the Logger.
    */
   constructor(roomId: string, channelManager: ChannelManager, chatApi: ChatApi, clientId: string, logger: Logger) {
-    super();
     this._roomId = roomId;
 
     this._channel = this._makeChannel(roomId, channelManager);
@@ -551,7 +547,8 @@ export class DefaultMessages
    */
   subscribe(listener: MessageListener): MessageSubscriptionResponse {
     this._logger.trace('Messages.subscribe();');
-    super.on([MessageEvents.Created, MessageEvents.Updated, MessageEvents.Deleted], listener);
+    const wrapped = wrap(listener);
+    this._emitter.on([MessageEvents.Created, MessageEvents.Updated, MessageEvents.Deleted], wrapped);
 
     // Set the subscription point to a promise that resolves when the channel attaches or with the latest message
     const resolvedSubscriptionStart = this._resolveSubscriptionStart();
@@ -563,17 +560,16 @@ export class DefaultMessages
       });
     });
 
-    this._listenerSubscriptionPoints.set(listener, resolvedSubscriptionStart);
+    this._listenerSubscriptionPoints.set(wrapped, resolvedSubscriptionStart);
 
     return {
       unsubscribe: () => {
-        // Remove the listener from the subscription points
-        this._listenerSubscriptionPoints.delete(listener);
+        // Remove the wrapped listener from the subscription points
+        this._listenerSubscriptionPoints.delete(wrapped);
         this._logger.trace('Messages.unsubscribe();');
-        super.off(listener);
+        this._emitter.off(wrapped);
       },
-      getPreviousMessages: (params: Omit<QueryOptions, 'orderBy'>) =>
-        this._getBeforeSubscriptionStart(listener, params),
+      getPreviousMessages: (params: Omit<QueryOptions, 'orderBy'>) => this._getBeforeSubscriptionStart(wrapped, params),
     };
   }
 
@@ -582,7 +578,7 @@ export class DefaultMessages
    */
   unsubscribeAll(): void {
     this._logger.trace('Messages.unsubscribeAll();');
-    super.off();
+    this._emitter.off();
     this._listenerSubscriptionPoints.clear();
   }
 
@@ -602,7 +598,7 @@ export class DefaultMessages
       return;
     }
 
-    this.emit(event, { type: event, message: message });
+    this._emitter.emit(event, { type: event, message: message });
   }
 
   /**
@@ -629,11 +625,12 @@ export class DefaultMessages
    */
   onDiscontinuity(listener: DiscontinuityListener): OnDiscontinuitySubscriptionResponse {
     this._logger.trace('Messages.onDiscontinuity();');
-    this._discontinuityEmitter.on(listener);
+    const wrapped = wrap(listener);
+    this._discontinuityEmitter.on(wrapped);
 
     return {
       off: () => {
-        this._discontinuityEmitter.off(listener);
+        this._discontinuityEmitter.off(wrapped);
       },
     };
   }
diff --git a/src/core/occupancy.ts b/src/core/occupancy.ts
index 33753444e..c4fa8bed4 100644
--- a/src/core/occupancy.ts
+++ b/src/core/occupancy.ts
@@ -4,7 +4,6 @@ import { messagesChannelName } from './channel.js';
 import { ChannelManager, ChannelOptionsMerger } from './channel-manager.js';
 import { ChatApi } from './chat-api.js';
 import {
-  DiscontinuityEmitter,
   DiscontinuityListener,
   EmitsDiscontinuities,
   HandlesDiscontinuity,
@@ -15,7 +14,7 @@ import { ErrorCodes } from './errors.js';
 import { Logger } from './logger.js';
 import { ContributesToRoomLifecycle } from './room-lifecycle-manager.js';
 import { Subscription } from './subscription.js';
-import EventEmitter from './utils/event-emitter.js';
+import EventEmitter, { wrap } from './utils/event-emitter.js';
 
 /**
  * This interface is used to interact with occupancy in a chat room: subscribing to occupancy updates and
@@ -84,15 +83,13 @@ interface OccupancyEventsMap {
 /**
  * @inheritDoc
  */
-export class DefaultOccupancy
-  extends EventEmitter<OccupancyEventsMap>
-  implements Occupancy, HandlesDiscontinuity, ContributesToRoomLifecycle
-{
+export class DefaultOccupancy implements Occupancy, HandlesDiscontinuity, ContributesToRoomLifecycle {
   private readonly _roomId: string;
   private readonly _channel: Ably.RealtimeChannel;
   private readonly _chatApi: ChatApi;
-  private _logger: Logger;
-  private _discontinuityEmitter: DiscontinuityEmitter = newDiscontinuityEmitter();
+  private readonly _logger: Logger;
+  private readonly _discontinuityEmitter = newDiscontinuityEmitter();
+  private readonly _emitter = new EventEmitter<OccupancyEventsMap>();
 
   /**
    * Constructs a new `DefaultOccupancy` instance.
@@ -102,8 +99,6 @@ export class DefaultOccupancy
    * @param logger An instance of the Logger.
    */
   constructor(roomId: string, channelManager: ChannelManager, chatApi: ChatApi, logger: Logger) {
-    super();
-
     this._roomId = roomId;
     this._channel = this._makeChannel(roomId, channelManager);
     this._chatApi = chatApi;
@@ -127,12 +122,13 @@ export class DefaultOccupancy
    */
   subscribe(listener: OccupancyListener): Subscription {
     this._logger.trace('Occupancy.subscribe();');
-    this.on(listener);
+    const wrapped = wrap(listener);
+    this._emitter.on(wrapped);
 
     return {
       unsubscribe: () => {
         this._logger.trace('Occupancy.unsubscribe();');
-        this.off(listener);
+        this._emitter.off(wrapped);
       },
     };
   }
@@ -142,7 +138,7 @@ export class DefaultOccupancy
    */
   unsubscribeAll(): void {
     this._logger.trace('Occupancy.unsubscribeAll();');
-    this.off();
+    this._emitter.off();
   }
 
   /**
@@ -218,7 +214,7 @@ export class DefaultOccupancy
       return;
     }
 
-    this.emit(OccupancyEvents.Occupancy, {
+    this._emitter.emit(OccupancyEvents.Occupancy, {
       connections: connections,
       presenceMembers: presenceMembers,
     });
@@ -226,11 +222,12 @@ export class DefaultOccupancy
 
   onDiscontinuity(listener: DiscontinuityListener): OnDiscontinuitySubscriptionResponse {
     this._logger.trace('Occupancy.onDiscontinuity();');
-    this._discontinuityEmitter.on(listener);
+    const wrapped = wrap(listener);
+    this._discontinuityEmitter.on(wrapped);
 
     return {
       off: () => {
-        this._discontinuityEmitter.off(listener);
+        this._discontinuityEmitter.off(wrapped);
       },
     };
   }
diff --git a/src/core/presence.ts b/src/core/presence.ts
index 8c485a3a2..9be1759c8 100644
--- a/src/core/presence.ts
+++ b/src/core/presence.ts
@@ -3,7 +3,6 @@ import * as Ably from 'ably';
 import { messagesChannelName } from './channel.js';
 import { ChannelManager, ChannelOptionsMerger } from './channel-manager.js';
 import {
-  DiscontinuityEmitter,
   DiscontinuityListener,
   EmitsDiscontinuities,
   HandlesDiscontinuity,
@@ -16,7 +15,7 @@ import { Logger } from './logger.js';
 import { ContributesToRoomLifecycle } from './room-lifecycle-manager.js';
 import { RoomOptions } from './room-options.js';
 import { Subscription } from './subscription.js';
-import EventEmitter from './utils/event-emitter.js';
+import EventEmitter, { wrap } from './utils/event-emitter.js';
 
 /**
  * Interface for PresenceEventsMap
@@ -174,14 +173,12 @@ export interface Presence extends EmitsDiscontinuities {
 /**
  * @inheritDoc
  */
-export class DefaultPresence
-  extends EventEmitter<PresenceEventsMap>
-  implements Presence, HandlesDiscontinuity, ContributesToRoomLifecycle
-{
+export class DefaultPresence implements Presence, HandlesDiscontinuity, ContributesToRoomLifecycle {
   private readonly _channel: Ably.RealtimeChannel;
   private readonly _clientId: string;
   private readonly _logger: Logger;
-  private readonly _discontinuityEmitter: DiscontinuityEmitter = newDiscontinuityEmitter();
+  private readonly _discontinuityEmitter = newDiscontinuityEmitter();
+  private readonly _emitter = new EventEmitter<PresenceEventsMap>();
 
   /**
    * Constructs a new `DefaultPresence` instance.
@@ -192,8 +189,6 @@ export class DefaultPresence
    * @param logger An instance of the Logger.
    */
   constructor(roomId: string, channelManager: ChannelManager, clientId: string, logger: Logger) {
-    super();
-
     this._channel = this._makeChannel(roomId, channelManager);
     this._clientId = clientId;
     this._logger = logger;
@@ -308,19 +303,21 @@ export class DefaultPresence
 
     // Add listener to all events
     if (listener) {
-      this.on(listenerOrEvents as PresenceEvents, listener);
+      const wrapped = wrap(listener);
+      this._emitter.on(listenerOrEvents as PresenceEvents, wrapped);
       return {
         unsubscribe: () => {
           this._logger.trace('Presence.unsubscribe();', { events: listenerOrEvents });
-          this.off(listener);
+          this._emitter.off(wrapped);
         },
       };
     } else {
-      this.on(listenerOrEvents as PresenceListener);
+      const wrapped = wrap(listenerOrEvents as PresenceListener);
+      this._emitter.on(wrapped);
       return {
         unsubscribe: () => {
           this._logger.trace('Presence.unsubscribe();');
-          this.off(listenerOrEvents as PresenceListener);
+          this._emitter.off(wrapped);
         },
       };
     }
@@ -331,7 +328,7 @@ export class DefaultPresence
    */
   unsubscribeAll(): void {
     this._logger.trace('Presence.unsubscribeAll()');
-    this.off();
+    this._emitter.off();
   }
 
   /**
@@ -343,7 +340,7 @@ export class DefaultPresence
   subscribeToEvents = (member: Ably.PresenceMessage) => {
     try {
       // Ably-js never emits the 'absent' event, so we can safely ignore it here.
-      this.emit(member.action as PresenceEvents, {
+      this._emitter.emit(member.action as PresenceEvents, {
         action: member.action as PresenceEvents,
         clientId: member.clientId,
         timestamp: member.timestamp,
@@ -363,11 +360,12 @@ export class DefaultPresence
 
   onDiscontinuity(listener: DiscontinuityListener): OnDiscontinuitySubscriptionResponse {
     this._logger.trace('Presence.onDiscontinuity();');
-    this._discontinuityEmitter.on(listener);
+    const wrapped = wrap(listener);
+    this._discontinuityEmitter.on(wrapped);
 
     return {
       off: () => {
-        this._discontinuityEmitter.off(listener);
+        this._discontinuityEmitter.off(wrapped);
       },
     };
   }
diff --git a/src/core/room-reactions.ts b/src/core/room-reactions.ts
index d94c76133..b07315a94 100644
--- a/src/core/room-reactions.ts
+++ b/src/core/room-reactions.ts
@@ -2,7 +2,6 @@ import * as Ably from 'ably';
 
 import { ChannelManager } from './channel-manager.js';
 import {
-  DiscontinuityEmitter,
   DiscontinuityListener,
   EmitsDiscontinuities,
   HandlesDiscontinuity,
@@ -16,7 +15,7 @@ import { Reaction, ReactionHeaders, ReactionMetadata } from './reaction.js';
 import { parseReaction } from './reaction-parser.js';
 import { ContributesToRoomLifecycle } from './room-lifecycle-manager.js';
 import { Subscription } from './subscription.js';
-import EventEmitter from './utils/event-emitter.js';
+import EventEmitter, { wrap } from './utils/event-emitter.js';
 
 /**
  * Params for sending a room-level reactions. Only `type` is mandatory.
@@ -121,14 +120,12 @@ interface ReactionEvent {
 /**
  * @inheritDoc
  */
-export class DefaultRoomReactions
-  extends EventEmitter<RoomReactionEventsMap>
-  implements RoomReactions, HandlesDiscontinuity, ContributesToRoomLifecycle
-{
+export class DefaultRoomReactions implements RoomReactions, HandlesDiscontinuity, ContributesToRoomLifecycle {
   private readonly _channel: Ably.RealtimeChannel;
   private readonly _clientId: string;
   private readonly _logger: Logger;
-  private readonly _discontinuityEmitter: DiscontinuityEmitter = newDiscontinuityEmitter();
+  private readonly _discontinuityEmitter = newDiscontinuityEmitter();
+  private readonly _emitter = new EventEmitter<RoomReactionEventsMap>();
 
   /**
    * Constructs a new `DefaultRoomReactions` instance.
@@ -138,8 +135,6 @@ export class DefaultRoomReactions
    * @param logger An instance of the Logger.
    */
   constructor(roomId: string, channelManager: ChannelManager, clientId: string, logger: Logger) {
-    super();
-
     this._channel = this._makeChannel(roomId, channelManager);
     this._clientId = clientId;
     this._logger = logger;
@@ -190,12 +185,13 @@ export class DefaultRoomReactions
    */
   subscribe(listener: RoomReactionListener): Subscription {
     this._logger.trace(`RoomReactions.subscribe();`);
-    this.on(listener);
+    const wrapped = wrap(listener);
+    this._emitter.on(wrapped);
 
     return {
       unsubscribe: () => {
         this._logger.trace('RoomReactions.unsubscribe();');
-        this.off(listener);
+        this._emitter.off(wrapped);
       },
     };
   }
@@ -205,7 +201,7 @@ export class DefaultRoomReactions
    */
   unsubscribeAll() {
     this._logger.trace(`RoomReactions.unsubscribeAll();`);
-    this.off();
+    this._emitter.off();
   }
 
   // parses reactions from realtime channel into Reaction objects and forwards them to the EventEmitter
@@ -215,7 +211,7 @@ export class DefaultRoomReactions
       // ignore non-reactions
       return;
     }
-    this.emit(RoomReactionEvents.Reaction, reaction);
+    this._emitter.emit(RoomReactionEvents.Reaction, reaction);
   };
 
   get channel(): Ably.RealtimeChannel {
@@ -237,11 +233,12 @@ export class DefaultRoomReactions
 
   onDiscontinuity(listener: DiscontinuityListener): OnDiscontinuitySubscriptionResponse {
     this._logger.trace('RoomReactions.onDiscontinuity();');
-    this._discontinuityEmitter.on(listener);
+    const wrapped = wrap(listener);
+    this._discontinuityEmitter.on(wrapped);
 
     return {
       off: () => {
-        this._discontinuityEmitter.off(listener);
+        this._discontinuityEmitter.off(wrapped);
       },
     };
   }
diff --git a/src/core/room-status.ts b/src/core/room-status.ts
index 1a1bfeb27..597baac65 100644
--- a/src/core/room-status.ts
+++ b/src/core/room-status.ts
@@ -2,7 +2,7 @@ import * as Ably from 'ably';
 
 import { Logger } from './logger.js';
 import { StatusSubscription } from './subscription.js';
-import EventEmitter from './utils/event-emitter.js';
+import EventEmitter, { wrap } from './utils/event-emitter.js';
 
 /**
  * The different states that a room can be in throughout its lifecycle.
@@ -156,20 +156,19 @@ type RoomStatusEventsMap = Record<RoomStatus, RoomStatusChange>;
  * An implementation of the `Status` interface.
  * @internal
  */
-export class DefaultRoomLifecycle extends EventEmitter<RoomStatusEventsMap> implements InternalRoomLifecycle {
+export class DefaultRoomLifecycle implements InternalRoomLifecycle {
   private _status: RoomStatus = RoomStatus.Initialized;
   private _error?: Ably.ErrorInfo;
   private readonly _logger: Logger;
   private readonly _internalEmitter = new EventEmitter<RoomStatusEventsMap>();
   private readonly _roomId: string;
-
+  private readonly _emitter = new EventEmitter<RoomStatusEventsMap>();
   /**
    * Constructs a new `DefaultStatus` instance.
    * @param roomId - The unique identifier for the room.
    * @param logger The logger to use.
    */
   constructor(roomId: string, logger: Logger) {
-    super();
     this._roomId = roomId;
     this._logger = logger;
     this._status = RoomStatus.Initialized;
@@ -194,11 +193,12 @@ export class DefaultRoomLifecycle extends EventEmitter<RoomStatusEventsMap> impl
    * @inheritdoc
    */
   onChange(listener: RoomStatusListener): StatusSubscription {
-    this.on(listener);
+    const wrapped = wrap(listener);
+    this._emitter.on(wrapped);
 
     return {
       off: () => {
-        this.off(listener);
+        this._emitter.off(wrapped);
       },
     };
   }
@@ -211,7 +211,7 @@ export class DefaultRoomLifecycle extends EventEmitter<RoomStatusEventsMap> impl
    * @inheritdoc
    */
   offAll(): void {
-    this.off();
+    this._emitter.off();
   }
 
   setStatus(params: NewRoomStatus): void {
@@ -225,6 +225,6 @@ export class DefaultRoomLifecycle extends EventEmitter<RoomStatusEventsMap> impl
     this._error = change.error;
     this._logger.info(`room status changed`, { ...change, roomId: this._roomId });
     this._internalEmitter.emit(change.current, change);
-    this.emit(change.current, change);
+    this._emitter.emit(change.current, change);
   }
 }
diff --git a/src/core/typing.ts b/src/core/typing.ts
index 4cc77f6f0..cd30da593 100644
--- a/src/core/typing.ts
+++ b/src/core/typing.ts
@@ -16,7 +16,7 @@ import { Logger } from './logger.js';
 import { ContributesToRoomLifecycle } from './room-lifecycle-manager.js';
 import { TypingOptions } from './room-options.js';
 import { Subscription } from './subscription.js';
-import EventEmitter from './utils/event-emitter.js';
+import EventEmitter, { wrap } from './utils/event-emitter.js';
 
 const PRESENCE_GET_RETRY_INTERVAL_MS = 1500; // base retry interval, we double it each time
 const PRESENCE_GET_RETRY_MAX_INTERVAL_MS = 30000; // max retry interval
@@ -219,12 +219,13 @@ export class DefaultTyping
    */
   subscribe(listener: TypingListener): Subscription {
     this._logger.trace(`DefaultTyping.subscribe();`);
-    this.on(listener);
+    const wrapped = wrap(listener);
+    this.on(wrapped);
 
     return {
       unsubscribe: () => {
         this._logger.trace('DefaultTyping.unsubscribe();');
-        this.off(listener);
+        this.off(wrapped);
       },
     };
   }
@@ -317,11 +318,12 @@ export class DefaultTyping
 
   onDiscontinuity(listener: DiscontinuityListener): OnDiscontinuitySubscriptionResponse {
     this._logger.trace(`DefaultTyping.onDiscontinuity();`);
-    this._discontinuityEmitter.on(listener);
+    const wrapped = wrap(listener);
+    this._discontinuityEmitter.on(wrapped);
 
     return {
       off: () => {
-        this._discontinuityEmitter.off(listener);
+        this._discontinuityEmitter.off(wrapped);
       },
     };
   }
diff --git a/src/core/utils/event-emitter.ts b/src/core/utils/event-emitter.ts
index a13bdffab..f3885dee2 100644
--- a/src/core/utils/event-emitter.ts
+++ b/src/core/utils/event-emitter.ts
@@ -60,3 +60,12 @@ const InternalEventEmitter: new <EventsMap>() => InterfaceEventEmitter<EventsMap
 class EventEmitter<EventsMap> extends InternalEventEmitter<EventsMap> {}
 
 export default EventEmitter;
+
+/**
+ * Creates a wrapper function that forwards all arguments to the provided function.
+ * @param fn The function to wrap
+ * @returns A new function with the same signature as the input function
+ */
+export const wrap = <Args extends unknown[], Return>(fn: (...args: Args) => Return): ((...args: Args) => Return) => {
+  return (...args: Args) => fn(...args);
+};
diff --git a/test/core/connection.test.ts b/test/core/connection.test.ts
index f73679551..978688858 100644
--- a/test/core/connection.test.ts
+++ b/test/core/connection.test.ts
@@ -120,6 +120,28 @@ describe('connection', () => {
       done();
     }));
 
+  it<TestContext>('subscriptions are unique even for same listener', (context) => {
+    const connection = new DefaultConnection(context.realtime, makeTestLogger());
+
+    let eventCount = 0;
+    const listener = () => {
+      eventCount++;
+    };
+
+    const s1 = connection.onStatusChange(listener);
+    const s2 = connection.onStatusChange(listener);
+    context.emulateStateChange({ current: 'connecting', previous: 'initialized' });
+    expect(eventCount).toEqual(2);
+
+    s1.off();
+    context.emulateStateChange({ current: 'connected', previous: 'connecting' });
+    expect(eventCount).toEqual(3);
+
+    s2.off();
+    context.emulateStateChange({ current: 'suspended', previous: 'connected' });
+    expect(eventCount).toEqual(3);
+  });
+
   describe.each([
     [ConnectionStatus.Connecting, AblyConnectionState.Initialized, AblyConnectionState.Connecting, undefined],
     [ConnectionStatus.Connected, AblyConnectionState.Connecting, AblyConnectionState.Connected, undefined],
diff --git a/test/core/messages.test.ts b/test/core/messages.test.ts
index 00e9dfa73..7795501db 100644
--- a/test/core/messages.test.ts
+++ b/test/core/messages.test.ts
@@ -157,6 +157,66 @@ describe('Messages', () => {
     });
   });
 
+  it<TestContext>('should only unsubscribe the correct subscription', (context) => {
+    const { room } = context;
+
+    const sendMessage = (serial: string) => {
+      const publishTimestamp = Date.now();
+      context.emulateBackendPublish({
+        clientId: 'yoda',
+        name: 'chat.message',
+        data: {
+          text: 'may the fourth be with you',
+        },
+        serial: serial,
+        version: serial,
+        action: ChatMessageActions.MessageCreate,
+        extras: {},
+        timestamp: publishTimestamp,
+        createdAt: publishTimestamp,
+      });
+    };
+
+    const received: string[] = [];
+    const listener = (message: MessageEvent) => {
+      received.push(message.message.serial);
+    };
+    const subscription1 = room.messages.subscribe(listener);
+    const subscription2 = room.messages.subscribe(listener);
+
+    sendMessage('a');
+    expect(received).toEqual(['a', 'a']);
+    subscription1.unsubscribe();
+    sendMessage('b');
+    expect(received).toEqual(['a', 'a', 'b']);
+    subscription2.unsubscribe();
+    sendMessage('c');
+    expect(received).toEqual(['a', 'a', 'b']);
+  });
+
+  it<TestContext>('should only unsubscribe the correct subscription for discontinuities', (context) => {
+    const { room } = context;
+
+    const received: string[] = [];
+    const listener = (error?: Ably.ErrorInfo) => {
+      received.push(error?.message ?? 'no error');
+    };
+
+    const subscription1 = room.messages.onDiscontinuity(listener);
+    const subscription2 = room.messages.onDiscontinuity(listener);
+
+    (room.messages as DefaultMessages).discontinuityDetected(new Ably.ErrorInfo('error1', 0, 0));
+    expect(received).toEqual(['error1', 'error1']);
+
+    subscription1.off();
+    (room.messages as DefaultMessages).discontinuityDetected(new Ably.ErrorInfo('error2', 0, 0));
+    expect(received).toEqual(['error1', 'error1', 'error2']);
+
+    subscription2.off();
+    (room.messages as DefaultMessages).discontinuityDetected(new Ably.ErrorInfo('error3', 0, 0));
+    expect(received).toEqual(['error1', 'error1', 'error2']);
+  });
+
   describe('subscribing to updates', () => {
     it<TestContext>('should subscribe to all message events', (context) =>
       new Promise<void>((done, reject) => {
diff --git a/test/core/occupancy.test.ts b/test/core/occupancy.test.ts
index a3ba61977..60b467da1 100644
--- a/test/core/occupancy.test.ts
+++ b/test/core/occupancy.test.ts
@@ -172,6 +172,72 @@ describe('Occupancy', () => {
     unsubscribe2();
   });
 
+  it<TestContext>('should only unsubscribe the correct subscription', (context) => {
+    const { room } = context;
+    const received: OccupancyEvent[] = [];
+
+    const emulateUpdate = (connections: number, presenceMembers: number) => {
+      context.emulateOccupancyUpdate({
+        name: '[meta]occupancy',
+        data: {
+          metrics: {
+            connections,
+            presenceMembers,
+          },
+        },
+      });
+    };
+
+    const listener = (event: OccupancyEvent) => {
+      received.push(event);
+    };
+
+    // Subscribe the same listener twice
+    const subscription1 = room.occupancy.subscribe(listener);
+    const subscription2 = room.occupancy.subscribe(listener);
+
+    // Both subscriptions should trigger the listener
+    emulateUpdate(5, 3);
+    expect(received).toHaveLength(2);
+
+    // Unsubscribe first subscription
+    subscription1.unsubscribe();
+
+    // One subscription should still trigger the listener
+    emulateUpdate(6, 4);
+    expect(received).toHaveLength(3);
+
+    // Unsubscribe second subscription
+    subscription2.unsubscribe();
+
+    // No subscriptions should trigger the listener
+    emulateUpdate(7, 5);
+    expect(received).toHaveLength(3);
+  });
+
+  it<TestContext>('should only unsubscribe the correct subscription for discontinuities', (context) => {
+    const { room } = context;
+
+    const received: string[] = [];
+    const listener = (error?: Ably.ErrorInfo) => {
+      received.push(error?.message ?? 'no error');
+    };
+
+    const subscription1 = room.occupancy.onDiscontinuity(listener);
+    const subscription2 = room.occupancy.onDiscontinuity(listener);
+
+    (room.occupancy as DefaultOccupancy).discontinuityDetected(new Ably.ErrorInfo('error1', 0, 0));
+    expect(received).toEqual(['error1', 'error1']);
+
+    subscription1.off();
+    (room.occupancy as DefaultOccupancy).discontinuityDetected(new Ably.ErrorInfo('error2', 0, 0));
+    expect(received).toEqual(['error1', 'error1', 'error2']);
+
+    subscription2.off();
+    (room.occupancy as DefaultOccupancy).discontinuityDetected(new Ably.ErrorInfo('error3', 0, 0));
+    expect(received).toEqual(['error1', 'error1', 'error2']);
+  });
+
   describe.each([
     ['invalid event name', { name: '[meta]occupancy2', data: { metrics: { connections: 5, presenceMembers: 6 } } }],
     ['no connections', { name: '[meta]occupancy', data: { metrics: { presenceMembers: 6 } } }],
diff --git a/test/core/presence.test.ts b/test/core/presence.test.ts
index be5fd5235..fc76580db 100644
--- a/test/core/presence.test.ts
+++ b/test/core/presence.test.ts
@@ -2,8 +2,9 @@ import * as Ably from 'ably';
 import { beforeEach, describe, expect, it, vi } from 'vitest';
 
 import { ChatApi } from '../../src/core/chat-api.ts';
-import { DefaultPresence } from '../../src/core/presence.ts';
+import { DefaultPresence, PresenceData, PresenceEvent } from '../../src/core/presence.ts';
 import { Room } from '../../src/core/room.ts';
+import { PresenceEvents } from '../../src/index.ts';
 import { makeTestLogger } from '../helper/logger.ts';
 import { makeRandomRoom } from '../helper/room.ts';
 
@@ -39,4 +40,74 @@ describe('Presence', () => {
       code: 40000,
     });
   });
+
+  it<TestContext>('should only unsubscribe the correct subscription', (context) => {
+    const { room } = context;
+    const received: PresenceEvent[] = [];
+
+    const emulatePresenceEvent = (clientId: string, action: PresenceEvents, data?: PresenceData) => {
+      const presenceMessage: Ably.PresenceMessage = {
+        action,
+        clientId,
+        timestamp: Date.now(),
+        data: data ? { userCustomData: data } : undefined,
+        connectionId: 'connection-id',
+        encoding: '',
+        id: 'message-id',
+        extras: null,
+      };
+
+      // Call the subscribeToEvents handler directly
+      (room.presence as DefaultPresence).subscribeToEvents(presenceMessage);
+    };
+
+    const listener = (event: PresenceEvent) => {
+      received.push(event);
+    };
+
+    // Subscribe the same listener twice
+    const subscription1 = room.presence.subscribe(listener);
+    const subscription2 = room.presence.subscribe(listener);
+
+    // Both subscriptions should trigger the listener
+    emulatePresenceEvent('user1', PresenceEvents.Enter, { foo: 'bar' });
+    expect(received).toHaveLength(2);
+
+    // Unsubscribe first subscription
+    subscription1.unsubscribe();
+
+    // One subscription should still trigger the listener
+    emulatePresenceEvent('user2', PresenceEvents.Enter, { baz: 'qux' });
+    expect(received).toHaveLength(3);
+
+    // Unsubscribe second subscription
+    subscription2.unsubscribe();
+
+    // No subscriptions should trigger the listener
+    emulatePresenceEvent('user3', PresenceEvents.Enter, { test: 'data' });
+    expect(received).toHaveLength(3);
+  });
+
+  it<TestContext>('should only unsubscribe the correct subscription for discontinuities', (context) => {
+    const { room } = context;
+
+    const received: string[] = [];
+    const listener = (error?: Ably.ErrorInfo) => {
+      received.push(error?.message ?? 'no error');
+    };
+
+    const subscription1 = room.presence.onDiscontinuity(listener);
+    const subscription2 = room.presence.onDiscontinuity(listener);
+
+    (room.presence as DefaultPresence).discontinuityDetected(new Ably.ErrorInfo('error1', 0, 0));
+    expect(received).toEqual(['error1', 'error1']);
+
+    subscription1.off();
+    (room.presence as DefaultPresence).discontinuityDetected(new Ably.ErrorInfo('error2', 0, 0));
+    expect(received).toEqual(['error1', 'error1', 'error2']);
+
+    subscription2.off();
+    (room.presence as DefaultPresence).discontinuityDetected(new Ably.ErrorInfo('error3', 0, 0));
+    expect(received).toEqual(['error1', 'error1', 'error2']);
+  });
 });
diff --git a/test/core/room-reactions.test.ts b/test/core/room-reactions.test.ts
index 31ff3f035..7a1a459f8 100644
--- a/test/core/room-reactions.test.ts
+++ b/test/core/room-reactions.test.ts
@@ -155,6 +155,78 @@ describe('Reactions', () => {
     );
   });
 
+  it<TestContext>('should only unsubscribe the correct subscription', (context) => {
+    const publishTimestamp = Date.now();
+    const { room } = context;
+
+    const received: string[] = [];
+    const listener = (reaction: Reaction) => {
+      received.push(reaction.type);
+    };
+    const subscription1 = room.reactions.subscribe(listener);
+    const subscription2 = room.reactions.subscribe(listener);
+
+    // Publish first reaction
+    context.emulateBackendPublish({
+      clientId: 'yoda',
+      name: 'roomReaction',
+      data: {
+        type: 'like',
+      },
+      timestamp: publishTimestamp,
+    });
+    expect(received).toEqual(['like', 'like']);
+
+    subscription1.unsubscribe();
+
+    // Publish second reaction
+    context.emulateBackendPublish({
+      clientId: 'yoda',
+      name: 'roomReaction',
+      data: {
+        type: 'love',
+      },
+      timestamp: publishTimestamp,
+    });
+    expect(received).toEqual(['like', 'like', 'love']);
+
+    subscription2.unsubscribe();
+
+    // Publish third reaction
+    context.emulateBackendPublish({
+      clientId: 'yoda',
+      name: 'roomReaction',
+      data: {
+        type: 'hug',
+      },
+      timestamp: publishTimestamp,
+    });
+    expect(received).toEqual(['like', 'like', 'love']);
+  });
+
+  it<TestContext>('should only unsubscribe the correct subscription for discontinuities', (context) => {
+    const { room } = context;
+
+    const received: string[] = [];
+    const listener = (error?: Ably.ErrorInfo) => {
+      received.push(error?.message ?? 'no error');
+    };
+
+    const subscription1 = room.reactions.onDiscontinuity(listener);
+    const subscription2 = room.reactions.onDiscontinuity(listener);
+
+    (room.reactions as DefaultRoomReactions).discontinuityDetected(new Ably.ErrorInfo('error1', 0, 0));
+    expect(received).toEqual(['error1', 'error1']);
+
+    subscription1.off();
+    (room.reactions as DefaultRoomReactions).discontinuityDetected(new Ably.ErrorInfo('error2', 0, 0));
+    expect(received).toEqual(['error1', 'error1', 'error2']);
+
+    subscription2.off();
+    (room.reactions as DefaultRoomReactions).discontinuityDetected(new Ably.ErrorInfo('error3', 0, 0));
+    expect(received).toEqual(['error1', 'error1', 'error2']);
+  });
+
   it<TestContext>('should be able to unsubscribe all reactions', (context) => {
     const publishTimestamp = Date.now();
     const { room } = context;
diff --git a/test/core/room-status.test.ts b/test/core/room-status.test.ts
index 6c9d9657a..3e8a70681 100644
--- a/test/core/room-status.test.ts
+++ b/test/core/room-status.test.ts
@@ -53,4 +53,29 @@ describe('room status', () => {
       status.setStatus({ status: RoomStatus.Attached, error: baseError });
       done();
     }));
+
+  it('subscriptions are unique even if listeners are identical', () =>
+    new Promise<void>((done, reject) => {
+      const status = new DefaultRoomLifecycle('roomId', makeTestLogger());
+
+      let eventCount = 0;
+      const listener = () => {
+        eventCount++;
+        if (eventCount > 3) {
+          reject(new Error('too many events received (' + eventCount.toString() + ')'));
+        }
+      };
+
+      const s1 = status.onChange(listener);
+      const s2 = status.onChange(listener);
+      status.setStatus({ status: RoomStatus.Attached, error: baseError });
+      expect(eventCount).toEqual(2);
+      s1.off();
+      status.setStatus({ status: RoomStatus.Attached, error: baseError });
+      expect(eventCount).toEqual(3);
+      s2.off();
+      status.setStatus({ status: RoomStatus.Attached, error: baseError });
+      expect(eventCount).toEqual(3);
+      done();
+    }));
 });
diff --git a/test/core/typing.test.ts b/test/core/typing.test.ts
index 49541dace..ab18262e9 100644
--- a/test/core/typing.test.ts
+++ b/test/core/typing.test.ts
@@ -225,6 +225,71 @@ describe('Typing', () => {
     unsubscribe2();
   });
 
+  it<TestContext>('should only unsubscribe the correct subscription', async (context) => {
+    const { room } = context;
+    const received: TypingEvent[] = [];
+
+    const emulateTypingEvent = (clientId: string, action: Ably.PresenceAction) => {
+      context.emulateBackendPublish({
+        clientId,
+        action,
+      });
+    };
+
+    const channel = context.room.typing.channel;
+    let arrayToReturn = presenceGetResponse([]);
+    vi.spyOn(channel.presence, 'get').mockImplementation(() => {
+      return Promise.resolve<Ably.PresenceMessage[]>(arrayToReturn);
+    });
+
+    const listener = (event: TypingEvent) => {
+      received.push(event);
+    };
+
+    // Subscribe the same listener twice
+    const subscription1 = room.typing.subscribe(listener);
+    const subscription2 = room.typing.subscribe(listener);
+
+    // Both subscriptions should trigger the listener
+    arrayToReturn = presenceGetResponse(['user1']);
+    emulateTypingEvent('user1', 'enter');
+    await waitForArrayLength(received, 2);
+
+    // Unsubscribe first subscription
+    subscription1.unsubscribe();
+
+    // One subscription should still trigger the listener
+    arrayToReturn = presenceGetResponse(['user1', 'user2']);
+    emulateTypingEvent('user2', 'enter');
+    await waitForArrayLength(received, 3);
+
+    // Unsubscribe second subscription
+    subscription2.unsubscribe();
+  });
+
+  it<TestContext>('should only unsubscribe the correct subscription for discontinuities', (context) => {
+    const { room } = context;
+
+    const received: string[] = [];
+    const listener = (error?: Ably.ErrorInfo) => {
+      received.push(error?.message ?? 'no error');
+    };
+
+    const subscription1 = room.typing.onDiscontinuity(listener);
+    const subscription2 = room.typing.onDiscontinuity(listener);
+
+    (room.typing as DefaultTyping).discontinuityDetected(new Ably.ErrorInfo('error1', 0, 0));
+    expect(received).toEqual(['error1', 'error1']);
+
+    subscription1.off();
+    (room.typing as DefaultTyping).discontinuityDetected(new Ably.ErrorInfo('error2', 0, 0));
+    expect(received).toEqual(['error1', 'error1', 'error2']);
+
+    subscription2.off();
+    (room.typing as DefaultTyping).discontinuityDetected(new Ably.ErrorInfo('error3', 0, 0));
+    expect(received).toEqual(['error1', 'error1', 'error2']);
+  });
+
   type PresenceTestParam = Omit<Ably.PresenceMessage, 'action' | 'clientId'>;
 
   describe.each([
diff --git a/test/react/helper/use-room-status.test.tsx b/test/react/helper/use-room-status.test.tsx
index e79675868..636b718eb 100644
--- a/test/react/helper/use-room-status.test.tsx
+++ b/test/react/helper/use-room-status.test.tsx
@@ -120,7 +120,9 @@ describe('useRoomStatus', () => {
     });
 
     // At this point we should have two listeners on the room status
-    expect((mockRoom as unknown as { _lifecycle: { listeners(): unknown[] } })._lifecycle.listeners()).toHaveLength(2);
+    expect(
+      (mockRoom as unknown as { _lifecycle: { _emitter: { listeners(): unknown[] } } })._lifecycle._emitter.listeners(),
+    ).toHaveLength(2);
 
     // Check the event
     expect(receivedEvents[0]?.current).toBe(RoomStatus.Failed);
@@ -146,6 +148,8 @@ describe('useRoomStatus', () => {
 
     // After unmount we should have no listeners
     unmount();
-    expect((mockRoom as unknown as { _lifecycle: { listeners(): unknown[] } })._lifecycle.listeners()).toBeNull();
+    expect(
+      (mockRoom as unknown as { _lifecycle: { _emitter: { listeners(): unknown[] } } })._lifecycle._emitter.listeners(),
+    ).toBeNull();
   });
 });


Reviews:
user: login: splindsay-92
body: Nice work! Do you think it's worth adding a test to cover this though? :) 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T10:10:00Z
user: login: AndyTWF
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T13:37:10Z
user: login: vladvelici
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T15:32:49Z
user: login: vladvelici
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T15:32:53Z
user: login: AndyTWF
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-03T15:39:24Z
user: login: splindsay-92
body: Nice, thanks for making the changes! 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T08:28:14Z

Review comments:
diff_hunk: @@ -60,3 +60,12 @@ const InternalEventEmitter: new <EventsMap>() => InterfaceEventEmitter<EventsMap
 class EventEmitter<EventsMap> extends InternalEventEmitter<EventsMap> {}
 
 export default EventEmitter;
+
+/**
+ * Creates a wrapper function that forwards all arguments to the provided function.
+ * @param fn The function to wrap
+ * @returns A new function with the same signature as the input function
+ */
+export function wrap<Args extends unknown[], Return>(fn: (...args: Args) => Return): (...args: Args) => Return {
path: src/core/utils/event-emitter.ts
user: login: AndyTWF
body: Nitpick: can we do this in the other format, e.g.

`export const wrap = (...) => ...`

To be consistent with elsewhere?
created_at: 2025-04-03T13:35:01Z
author_association: COLLABORATOR
diff_hunk: @@ -194,11 +193,12 @@ export class DefaultRoomLifecycle extends EventEmitter<RoomStatusEventsMap> impl
    * @inheritdoc
    */
   onChange(listener: RoomStatusListener): StatusSubscription {
-    this.on(listener);
+    listener = wrap(listener);
path: src/core/room-status.ts
user: login: AndyTWF
body: Nitpick: I think its cleaner to do something like:

`const wrapped = wrap(listener)` rather than reassigning
created_at: 2025-04-03T13:36:09Z
author_association: COLLABORATOR
diff_hunk: @@ -194,11 +193,12 @@ export class DefaultRoomLifecycle extends EventEmitter<RoomStatusEventsMap> impl
    * @inheritdoc
    */
   onChange(listener: RoomStatusListener): StatusSubscription {
-    this.on(listener);
+    listener = wrap(listener);
path: src/core/room-status.ts
user: login: AndyTWF
body: (And in other places)
created_at: 2025-04-03T13:37:07Z
author_association: COLLABORATOR
diff_hunk: @@ -60,3 +60,12 @@ const InternalEventEmitter: new <EventsMap>() => InterfaceEventEmitter<EventsMap
 class EventEmitter<EventsMap> extends InternalEventEmitter<EventsMap> {}
 
 export default EventEmitter;
+
+/**
+ * Creates a wrapper function that forwards all arguments to the provided function.
+ * @param fn The function to wrap
+ * @returns A new function with the same signature as the input function
+ */
+export function wrap<Args extends unknown[], Return>(fn: (...args: Args) => Return): (...args: Args) => Return {
path: src/core/utils/event-emitter.ts
user: login: vladvelici
body: done
created_at: 2025-04-03T15:32:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -194,11 +193,12 @@ export class DefaultRoomLifecycle extends EventEmitter<RoomStatusEventsMap> impl
    * @inheritdoc
    */
   onChange(listener: RoomStatusListener): StatusSubscription {
-    this.on(listener);
+    listener = wrap(listener);
path: src/core/room-status.ts
user: login: vladvelici
body: done
created_at: 2025-04-03T15:32:52Z
author_association: CONTRIBUTOR

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-04-03T08:20:06Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- This is an auto-generated comment: skip review by coderabbit.ai -->

> [!IMPORTANT]
> ## Review skipped
> 
> Auto reviews are disabled on this repository.
> 
> Please check the settings in the CodeRabbit UI or the `.coderabbit.yaml` file in this repository. To trigger a single review, invoke the `@coderabbitai review` command.
> 
> You can disable this status message by setting the `reviews.review_status` to `false` in the CodeRabbit configuration file.

<!-- end of auto-generated comment: skip review by coderabbit.ai -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=ably/ably-chat-js&utm_content=518):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai plan` to trigger planning for file edits and PR creation.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-03T08:21:48Z
body: <h2>Coverage Report</h2>
<table> <thead> <tr> <th align="center">Status</th> <th align="left">Category</th> <th align="right">Percentage</th> <th align="right">Covered / Total</th> </tr> </thead> <tbody> <tr> <td align="center">🟢</td> <td align="left">Lines</td> <td align="right">94.13% (🎯 92%)</td> <td align="right">3307 / 3513</td> </tr> <tr> <td align="center">🟢</td> <td align="left">Statements</td> <td align="right">94.13% (🎯 92%)</td> <td align="right">3307 / 3513</td> </tr> <tr> <td align="center">🟢</td> <td align="left">Functions</td> <td align="right">98.85% (🎯 92%)</td> <td align="right">258 / 261</td> </tr> <tr> <td align="center">🟢</td> <td align="left">Branches</td> <td align="right">95.3% (🎯 93%)</td> <td align="right">853 / 895</td> </tr> </tbody> </table><details><summary>File Coverage</summary><table> <thead> <tr> <th align="left">File</th> <th align="right">Stmts</th> <th align="right">Branches</th> <th align="right">Functions</th> <th align="right">Lines</th> <th align="left">Uncovered Lines</th> </tr> </thead> <tbody> <tr> <td align="left" colspan="6"><b>Changed Files</b></td> </tr> <tr> <td align="left"><a href="https://github.com/ably/ably-chat-js/blob/c30abf0c5f52446eb4f0fd8698b517fb13aedf09/src/core/connection.ts">src/core/connection.ts</a></td> <td align="right">97.87%</td> <td align="right">96.29%</td> <td align="right">100%</td> <td align="right">97.87%</td> <td align="left"><a href="https://github.com/ably/ably-chat-js/blob/c30abf0c5f52446eb4f0fd8698b517fb13aedf09/src/core/connection.ts#L164-L165" class="text-red">164-165</a></td> </tr> <tr> <td align="left"><a href="https://github.com/ably/ably-chat-js/blob/c30abf0c5f52446eb4f0fd8698b517fb13aedf09/src/core/messages.ts">src/core/messages.ts</a></td> <td align="right">92.8%</td> <td align="right">91.83%</td> <td align="right">100%</td> <td align="right">92.8%</td> <td align="left"><a href="https://github.com/ably/ably-chat-js/blob/c30abf0c5f52446eb4f0fd8698b517fb13aedf09/src/core/messages.ts#L386-L388" class="text-red">386-388</a>, <a href="https://github.com/ably/ably-chat-js/blob/c30abf0c5f52446eb4f0fd8698b517fb13aedf09/src/core/messages.ts#L418-L422" class="text-red">418-422</a>, <a href="https://github.com/ably/ably-chat-js/blob/c30abf0c5f52446eb4f0fd8698b517fb13aedf09/src/core/messages.ts#L434-L438" class="text-red">434-438</a>, <a href="https://github.com/ably/ably-chat-js/blob/c30abf0c5f52446eb4f0fd8698b517fb13aedf09/src/core/messages.ts#L558-L560" class="text-red">558-560</a>, <a href="https://github.com/ably/ably-chat-js/blob/c30abf0c5f52446eb4f0fd8698b517fb13aedf09/src/core/messages.ts#L592-L594" class="text-red">592-594</a></td> </tr> <tr> <td align="left"><a href="https://github.com/ably/ably-chat-js/blob/c30abf0c5f52446eb4f0fd8698b517fb13aedf09/src/core/occupancy.ts">src/core/occupancy.ts</a></td> <td align="right">100%</td> <td align="right">100%</td> <td align="right">100%</td> <td align="right">100%</td> <td align="left"></td> </tr> <tr> <td align="left"><a href="https://github.com/ably/ably-chat-js/blob/c30abf0c5f52446eb4f0fd8698b517fb13aedf09/src/core/presence.ts">src/core/presence.ts</a></td> <td align="right">94.44%</td> <td align="right">97.05%</td> <td align="right">100%</td> <td align="right">94.44%</td> <td align="left"><a href="https://github.com/ably/ably-chat-js/blob/c30abf0c5f52446eb4f0fd8698b517fb13aedf09/src/core/presence.ts#L351-L358" class="text-red">351-358</a></td> </tr> <tr> <td align="left"><a href="https://github.com/ably/ably-chat-js/blob/c30abf0c5f52446eb4f0fd8698b517fb13aedf09/src/core/room-reactions.ts">src/core/room-reactions.ts</a></td> <td align="right">97.8%</td> <td align="right">95%</td> <td align="right">100%</td> <td align="right">97.8%</td> <td align="left"><a href="https://github.com/ably/ably-chat-js/blob/c30abf0c5f52446eb4f0fd8698b517fb13aedf09/src/core/room-reactions.ts#L164-L165" class="text-red">164-165</a></td> </tr> <tr> <td align="left"><a href="https://github.com/ably/ably-chat-js/blob/c30abf0c5f52446eb4f0fd8698b517fb13aedf09/src/core/room-status.ts">src/core/room-status.ts</a></td> <td align="right">100%</td> <td align="right">100%</td> <td align="right">100%</td> <td align="right">100%</td> <td align="left"></td> </tr> <tr> <td align="left"><a href="https://github.com/ably/ably-chat-js/blob/c30abf0c5f52446eb4f0fd8698b517fb13aedf09/src/core/typing.ts">src/core/typing.ts</a></td> <td align="right">92.63%</td> <td align="right">94.73%</td> <td align="right">100%</td> <td align="right">92.63%</td> <td align="left"><a href="https://github.com/ably/ably-chat-js/blob/c30abf0c5f52446eb4f0fd8698b517fb13aedf09/src/core/typing.ts#L255-L258" class="text-red">255-258</a>, <a href="https://github.com/ably/ably-chat-js/blob/c30abf0c5f52446eb4f0fd8698b517fb13aedf09/src/core/typing.ts#L268-L271" class="text-red">268-271</a>, <a href="https://github.com/ably/ably-chat-js/blob/c30abf0c5f52446eb4f0fd8698b517fb13aedf09/src/core/typing.ts#L296-L297" class="text-red">296-297</a>, <a href="https://github.com/ably/ably-chat-js/blob/c30abf0c5f52446eb4f0fd8698b517fb13aedf09/src/core/typing.ts#L301-L302" class="text-red">301-302</a></td> </tr> <tr> <td align="left"><a href="https://github.com/ably/ably-chat-js/blob/c30abf0c5f52446eb4f0fd8698b517fb13aedf09/src/core/utils/event-emitter.ts">src/core/utils/event-emitter.ts</a></td> <td align="right">100%</td> <td align="right">100%</td> <td align="right">100%</td> <td align="right">100%</td> <td align="left"></td> </tr> </tbody> </table></details>
<em>Generated in workflow <a href=https://github.com/ably/ably-chat-js/actions/runs/14260654016>#2181</a> for commit <a href="https://github.com/ably/ably-chat-js/commit/c30abf0c5f52446eb4f0fd8698b517fb13aedf09">c30abf0</a> by the <a href="https://github.com/davelosert/vitest-coverage-report-action">Vitest Coverage Report Action</a></em>

<!-- vitest-coverage-report-marker-root -->
author_association: NONE
user: login: vladvelici
created_at: 2025-04-04T07:52:39Z
body: @splindsay-92 I've added tests and found some listeners I forgot to wrap initially.
author_association: CONTRIBUTOR