Repository: knocklabs/javascript
Pull request #513: feat(KNO-8548): add feed socket manager
Author: login: mattmikolay
Created: 2025-05-02T13:32:42Z | Merged: 2025-05-05T16:26:11Z

Pre-req: knocklabs/switchboard#3973

This PR adds a socket manager that’s used with feed clients, so that multiple feeds can subscribe to messages sent over a single Phoenix channel.

Due to how Phoenix channels work, [“the client may only hold a single subscription for each unique topic at any given time.”](https://github.com/phoenixframework/phoenix/blob/ec6ed946b4fb0803590de742f53cdba43d3eba33/assets/js/phoenix/index.js#L61-L69) Prior to this PR, whenever multiple instances of `Feed` attempted joined the same topic on a Phoenix channel, only the `Feed` that joined most recently would maintain its channel connection. Phoenix would automatically close the channels associated with all prior `Feed` instances.

This PR implements the same approach we previously implemented in #230 for in-app messages.

I tested these changes locally, and tried my best to be thorough. Here are the scenarios I tested:
- Single feed client receives real-time updates with `auto_manage_socket_connection` option disabled (default)
- Single feed client receives real-time updates after disconnecting and reconnecting when `auto_manage_socket_connection` option is enabled
- Multiple feed clients receives real-time updates with `auto_manage_socket_connection` option disabled (default)
- Multiple feed clients receives real-time updates after disconnecting and reconnecting when `auto_manage_socket_connection` option is enabled
- After a socket connection error occurs, but the connection is restored, messages are still received
- Messages are received after re-authenticating as a different user

## Video

https://www.loom.com/share/b2e05fd9252047f9b2f5e42b61c2bddd?sid=74deb3b5-d713-4d30-815b-d51b04b81fea


Diff:
diff --git a/.changeset/major-moles-talk.md b/.changeset/major-moles-talk.md
new file mode 100644
index 000000000..70ff41819
--- /dev/null
+++ b/.changeset/major-moles-talk.md
@@ -0,0 +1,9 @@
+---
+"@knocklabs/client": patch
+---
+
+Allow multiple instances of `Feed` to listen for real-time updates to the same notification feed
+
+Previously, using two or more instances of `Feed` with the same in-app feed channel would result in
+only the most recently connected `Feed` receiving real-time updates. Now, all instances of `Feed`
+configured with the same in-app channel will receive real-time updates.
diff --git a/packages/client/package.json b/packages/client/package.json
index 9de071be5..bcb17ba26 100644
--- a/packages/client/package.json
+++ b/packages/client/package.json
@@ -77,6 +77,7 @@
     "axios-retry": "^4.5.0",
     "eventemitter2": "^6.4.5",
     "jwt-decode": "^4.0.0",
+    "nanoid": "^5.1.5",
     "phoenix": "1.7.19",
     "urlpattern-polyfill": "^10.0.0",
     "zustand": "^4.5.6"
diff --git a/packages/client/src/clients/feed/feed.ts b/packages/client/src/clients/feed/feed.ts
index 0fc2a07cc..12fe11033 100644
--- a/packages/client/src/clients/feed/feed.ts
+++ b/packages/client/src/clients/feed/feed.ts
@@ -1,6 +1,6 @@
 import { GenericData } from "@knocklabs/types";
 import EventEmitter from "eventemitter2";
-import { Channel } from "phoenix";
+import { nanoid } from "nanoid";
 import type { StoreApi } from "zustand";
 
 import { isValidUuid } from "../../helpers";
@@ -19,6 +19,11 @@ import {
   FetchFeedOptions,
   FetchFeedOptionsForRequest,
 } from "./interfaces";
+import {
+  FeedSocketManager,
+  SocketEventPayload,
+  SocketEventType,
+} from "./socket-manager";
 import createStore from "./store";
 import {
   BindableFeedEvent,
@@ -39,11 +44,15 @@ const feedClientDefaults: Pick<FeedClientOptions, "archived"> = {
 
 const DEFAULT_DISCONNECT_DELAY = 2000;
 
+const CLIENT_REF_ID_PREFIX = "client_";
+
 class Feed {
+  public readonly defaultOptions: FeedClientOptions;
+  public readonly referenceId: string;
+  public unsubscribeFromSocketEvents: (() => void) | undefined = undefined;
+  private socketManager: FeedSocketManager | undefined;
   private userFeedId: string;
-  private channel?: Channel;
   private broadcaster: EventEmitter;
-  private defaultOptions: FeedClientOptions;
   private broadcastChannel!: BroadcastChannel | null;
   private disconnectTimer: ReturnType<typeof setTimeout> | null = null;
   private hasSubscribedToRealTimeUpdates: boolean = false;
@@ -57,6 +66,7 @@ class Feed {
     readonly knock: Knock,
     readonly feedId: string,
     options: FeedClientOptions,
+    socketManager: FeedSocketManager | undefined,
   ) {
     if (!feedId || !isValidUuid(feedId)) {
       this.knock.log(
@@ -67,6 +77,8 @@ class Feed {
 
     this.feedId = feedId;
     this.userFeedId = this.buildUserFeedId();
+    this.referenceId = CLIENT_REF_ID_PREFIX + nanoid();
+    this.socketManager = socketManager;
     this.store = createStore();
     this.broadcaster = new EventEmitter({ wildcard: true, delimiter: "." });
     this.defaultOptions = {
@@ -84,7 +96,9 @@ class Feed {
   /**
    * Used to reinitialize a current feed instance, which is useful when reauthenticating users
    */
-  reinitialize() {
+  reinitialize(socketManager?: FeedSocketManager) {
+    this.socketManager = socketManager;
+
     // Reinitialize the user feed id incase the userId changed
     this.userFeedId = this.buildUserFeedId();
 
@@ -102,12 +116,9 @@ class Feed {
   teardown() {
     this.knock.log("[Feed] Tearing down feed instance");
 
-    if (this.channel) {
-      this.channel.leave();
-      this.channel.off("new-message");
-    }
+    this.socketManager?.leave(this);
 
-    this.teardownAutoSocketManager();
+    this.tearDownVisibilityListeners();
 
     if (this.disconnectTimer) {
       clearTimeout(this.disconnectTimer);
@@ -144,17 +155,7 @@ class Feed {
       return;
     }
 
-    const maybeSocket = this.knock.client().socket;
-
-    // Connect the socket only if we don't already have a connection
-    if (maybeSocket && !maybeSocket.isConnected()) {
-      maybeSocket.connect();
-    }
-
-    // Only join the channel if we're not already in a joining state
-    if (this.channel && ["closed", "errored"].includes(this.channel.state)) {
-      this.channel.join();
-    }
+    this.unsubscribeFromSocketEvents = this.socketManager?.join(this);
   }
 
   /* Binds a handler to be invoked when event occurs */
@@ -587,6 +588,10 @@ class Feed {
     });
   }
 
+  get socketChannelTopic(): string {
+    return `feeds:${this.userFeedId}`;
+  }
+
   private broadcast(
     eventName: FeedEvent,
     data: FeedResponse | FeedEventPayload,
@@ -595,15 +600,19 @@ class Feed {
   }
 
   // Invoked when a new real-time message comes in from the socket
-  private async onNewMessageReceived({
-    metadata,
-  }: FeedMessagesReceivedPayload) {
+  private async onNewMessageReceived({ data }: FeedMessagesReceivedPayload) {
     this.knock.log("[Feed] Received new real-time message");
+
     // Handle the new message coming in
     const { items, ...state } = this.store.getState();
     const currentHead: FeedItem | undefined = items[0];
+
     // Optimistically set the badge counts
-    state.setMetadata(metadata);
+    const metadata = data[this.referenceId]?.metadata;
+    if (metadata) {
+      state.setMetadata(metadata);
+    }
+
     // Fetch the items before the current head (if it exists)
     this.fetch({ before: currentHead?.__cursor, __fetchSource: "socket" });
   }
@@ -767,28 +776,29 @@ class Feed {
   }
 
   private initializeRealtimeConnection() {
-    const { socket: maybeSocket } = this.knock.client();
-
     // In server environments we might not have a socket connection
-    if (!maybeSocket) return;
-
-    // Reinitialize channel connections incase the socket changed
-    this.channel = maybeSocket.channel(
-      `feeds:${this.userFeedId}`,
-      this.defaultOptions,
-    );
-
-    this.channel.on("new-message", (resp) => this.onNewMessageReceived(resp));
+    if (!this.socketManager) return;
 
     if (this.defaultOptions.auto_manage_socket_connection) {
-      this.setupAutoSocketManager();
+      this.setUpVisibilityListeners();
     }
 
     // If we're initializing but they have previously opted to listen to real-time updates
     // then we will automatically reconnect on their behalf
     if (this.hasSubscribedToRealTimeUpdates && this.knock.isAuthenticated()) {
-      if (!maybeSocket.isConnected()) maybeSocket.connect();
-      this.channel.join();
+      this.unsubscribeFromSocketEvents = this.socketManager?.join(this);
+    }
+  }
+
+  async handleSocketEvent(payload: SocketEventPayload) {
+    switch (payload.event) {
+      case SocketEventType.NewMessage:
+        this.onNewMessageReceived(payload);
+        return;
+      default: {
+        const _exhaustiveCheck: never = payload.event;
+        return;
+      }
     }
   }
 
@@ -796,7 +806,7 @@ class Feed {
    * Listen for changes to document visibility and automatically disconnect
    * or reconnect the socket after a delay
    */
-  private setupAutoSocketManager() {
+  private setUpVisibilityListeners() {
     if (
       typeof document === "undefined" ||
       this.visibilityChangeListenerConnected
@@ -809,7 +819,7 @@ class Feed {
     document.addEventListener("visibilitychange", this.visibilityChangeHandler);
   }
 
-  private teardownAutoSocketManager() {
+  private tearDownVisibilityListeners() {
     if (typeof document === "undefined") return;
 
     document.removeEventListener(
@@ -860,7 +870,7 @@ class Feed {
 
       // If the socket is not connected, try to reconnect
       if (!client.socket?.isConnected()) {
-        this.initializeRealtimeConnection();
+        client.socket?.connect();
       }
     }
   }
diff --git a/packages/client/src/clients/feed/index.ts b/packages/client/src/clients/feed/index.ts
index 0614497d5..d62505779 100644
--- a/packages/client/src/clients/feed/index.ts
+++ b/packages/client/src/clients/feed/index.ts
@@ -2,19 +2,27 @@ import Knock from "../../knock";
 
 import Feed from "./feed";
 import { FeedClientOptions } from "./interfaces";
+import { FeedSocketManager } from "./socket-manager";
 
 class FeedClient {
   private instance: Knock;
   private feedInstances: Feed[] = [];
+  private socketManager: FeedSocketManager | undefined;
 
   constructor(instance: Knock) {
     this.instance = instance;
   }
 
   initialize(feedChannelId: string, options: FeedClientOptions = {}) {
-    const feedInstance = new Feed(this.instance, feedChannelId, options);
+    this.initSocketManager();
+
+    const feedInstance = new Feed(
+      this.instance,
+      feedChannelId,
+      options,
+      this.socketManager,
+    );
     this.feedInstances.push(feedInstance);
-
     return feedInstance;
   }
 
@@ -30,7 +38,23 @@ class FeedClient {
 
   reinitializeInstances() {
     for (const feed of this.feedInstances) {
-      feed.reinitialize();
+      this.socketManager?.leave(feed);
+    }
+
+    // The API client has a new socket once it's reinitialized,
+    // so we need to set up a new socket manager
+    this.socketManager = undefined;
+    this.initSocketManager();
+
+    for (const feed of this.feedInstances) {
+      feed.reinitialize(this.socketManager);
+    }
+  }
+
+  private initSocketManager() {
+    const socket = this.instance.client().socket;
+    if (socket && !this.socketManager) {
+      this.socketManager = new FeedSocketManager(socket);
     }
   }
 }
diff --git a/packages/client/src/clients/feed/socket-manager.ts b/packages/client/src/clients/feed/socket-manager.ts
new file mode 100644
index 000000000..702134f5c
--- /dev/null
+++ b/packages/client/src/clients/feed/socket-manager.ts
@@ -0,0 +1,185 @@
+import { Store } from "@tanstack/store";
+import { Channel, Socket } from "phoenix";
+
+import Feed from "./feed";
+import type { FeedClientOptions, FeedMetadata } from "./interfaces";
+
+export const SocketEventType = {
+  NewMessage: "new-message",
+} as const;
+
+const SOCKET_EVENT_TYPES = [SocketEventType.NewMessage];
+
+type ClientQueryParams = FeedClientOptions;
+
+// e.g. feeds:<channel_id>:<user_id>
+type ChannelTopic = string;
+
+// Unique reference id of a feed client
+type ClientReferenceId = string;
+
+type NewMessageEventPayload = {
+  event: typeof SocketEventType.NewMessage;
+  /**
+   * @deprecated Top-level feed metadata. Exists for legacy reasons.
+   */
+  metadata: FeedMetadata;
+  /** Feed metadata, keyed by client reference id. */
+  data: Record<ClientReferenceId, { metadata: FeedMetadata }>;
+};
+
+export type SocketEventPayload = NewMessageEventPayload;
+
+// "attn" field contains a list of client reference ids that should be notified
+// of a socket event.
+type WithAttn<P> = P & { attn: ClientReferenceId[] };
+
+type FeedSocketInbox = Record<ClientReferenceId, SocketEventPayload>;
+
+/*
+ * Manages socket subscriptions for feeds, allowing multiple feed clients
+ * to listen for real time updates from the socket API via a single socket
+ * connection.
+ */
+export class FeedSocketManager {
+  // Mapping of live channels by topic. Note, there can be one or more feed
+  // client(s) that can subscribe.
+  private channels: Record<ChannelTopic, Channel>;
+
+  // Mapping of query params for each feeds client, partitioned by reference id,
+  // and grouped by channel topic. It's a double nested object that looks like:
+  //  {
+  //    "feeds:<channel_1>:<user_1>": {
+  //      "ref-1": {
+  //        "tenant": "foo",
+  //      },
+  //      "ref-2": {
+  //        "tenant": "bar",
+  //      },
+  //    },
+  //    "feeds:<channel_2>:<user_1>": {
+  //      "ref-3": {
+  //        "tenant": "baz",
+  //      },
+  //    }
+  //  }
+  //
+  // Each time a new feed client joins a channel, we send all cumulated
+  // params such that the socket API can apply filtering rules and figure out
+  // which feed clients should be notified based on reference ids in
+  // "attn" field of the event payload when sending out an event.
+  private params: Record<
+    ChannelTopic,
+    Record<ClientReferenceId, ClientQueryParams>
+  >;
+
+  // A reactive store that captures a new socket event, that notifies any feed
+  // clients that have subscribed.
+  private inbox: Store<
+    FeedSocketInbox,
+    (cb: FeedSocketInbox) => FeedSocketInbox
+  >;
+
+  constructor(readonly socket: Socket) {
+    this.channels = {};
+    this.params = {};
+    this.inbox = new Store<FeedSocketInbox>({});
+  }
+
+  join(feed: Feed) {
+    const topic = feed.socketChannelTopic;
+    const referenceId = feed.referenceId;
+    const params = feed.defaultOptions;
+
+    // Ensure a live socket connection if not yet connected.
+    if (!this.socket.isConnected()) {
+      this.socket.connect();
+    }
+
+    // If a new feed client joins, or has updated query params, then
+    // track the updated params and (re)join with the latest query params.
+    // Note, each time we send combined params of all feed clients that
+    // have subscribed for a given feed channel and user, grouped by
+    // client's reference id.
+    if (!this.params[topic]) {
+      this.params[topic] = {};
+    }
+
+    const maybeParams = this.params[topic][referenceId];
+    const hasNewOrUpdatedParams =
+      !maybeParams || JSON.stringify(maybeParams) !== JSON.stringify(params);
+
+    if (hasNewOrUpdatedParams) {
+      // Tracks all subscribed clients' params by reference id and by topic.
+      this.params[topic] = { ...this.params[topic], [referenceId]: params };
+    }
+
+    if (!this.channels[topic] || hasNewOrUpdatedParams) {
+      const newChannel = this.socket.channel(topic, this.params[topic]);
+      for (const eventType of SOCKET_EVENT_TYPES) {
+        newChannel.on(eventType, (payload) => this.setInbox(payload));
+      }
+      // Tracks live channels by channel topic.
+      this.channels[topic] = newChannel;
+    }
+
+    const channel = this.channels[topic];
+
+    // Join the channel if not already joined or joining or leaving.
+    if (["closed", "errored"].includes(channel.state)) {
+      channel.join();
+    }
+
+    // Let the feed client subscribe to the "inbox", so it can be notified
+    // when there's a new socket event that is relevant to it
+    const unsub = this.inbox.subscribe(() => {
+      const payload = this.inbox.state[referenceId];
+      if (!payload) return;
+
+      feed.handleSocketEvent(payload);
+    });
+
+    return unsub;
+  }
+
+  leave(feed: Feed) {
+    feed.unsubscribeFromSocketEvents?.();
+
+    const topic = feed.socketChannelTopic;
+    const referenceId = feed.referenceId;
+
+    const partitionedParams = { ...this.params };
+    const paramsForTopic = partitionedParams[topic] || {};
+    const paramsForReferenceClient = paramsForTopic[referenceId];
+
+    if (paramsForReferenceClient) {
+      delete paramsForTopic[referenceId];
+    }
+
+    const channels = { ...this.channels };
+    const channelForTopic = channels[topic];
+    if (channelForTopic && Object.keys(paramsForTopic).length === 0) {
+      for (const eventType of SOCKET_EVENT_TYPES) {
+        channelForTopic.off(eventType);
+      }
+      channelForTopic.leave();
+      delete channels[topic];
+    }
+
+    this.params = partitionedParams;
+    this.channels = channels;
+  }
+
+  private setInbox(payload: WithAttn<SocketEventPayload>) {
+    const { attn, ...rest } = payload;
+
+    // Set the incoming socket event into the inbox, keyed by relevant client
+    // reference ids provided by the server (via attn field), so we can notify
+    // only the clients that need to be notified.
+    this.inbox.setState(() =>
+      attn.reduce((acc, referenceId) => {
+        return { ...acc, [referenceId]: rest };
+      }, {}),
+    );
+  }
+}
diff --git a/packages/client/src/clients/feed/types.ts b/packages/client/src/clients/feed/types.ts
index edac8bb93..b83df1d62 100644
--- a/packages/client/src/clients/feed/types.ts
+++ b/packages/client/src/clients/feed/types.ts
@@ -3,6 +3,7 @@ import { GenericData, PageInfo } from "@knocklabs/types";
 import { NetworkStatus } from "../../networkStatus";
 
 import { FeedItem, FeedMetadata, FeedResponse } from "./interfaces";
+import { SocketEventPayload, SocketEventType } from "./socket-manager";
 
 export type StoreFeedResultOptions = {
   shouldSetPage?: boolean;
@@ -22,9 +23,10 @@ export interface FeedStoreState {
   resetStore: (metadata?: FeedMetadata) => void;
 }
 
-export interface FeedMessagesReceivedPayload {
-  metadata: FeedMetadata;
-}
+export type FeedMessagesReceivedPayload = Extract<
+  SocketEventPayload,
+  { event: typeof SocketEventType.NewMessage }
+>;
 
 /*
 Event types:
diff --git a/packages/client/test/socket-manager.test.ts b/packages/client/test/socket-manager.test.ts
new file mode 100644
index 000000000..6e5c24f88
--- /dev/null
+++ b/packages/client/test/socket-manager.test.ts
@@ -0,0 +1,185 @@
+import EventEmitter from "eventemitter2";
+import { Channel, Socket } from "phoenix";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { StoreApi } from "zustand";
+
+import Feed from "../src/clients/feed/feed";
+import {
+  FeedSocketManager,
+  SocketEventType,
+} from "../src/clients/feed/socket-manager";
+import Knock from "../src/knock";
+
+const TEST_CLIENT_REF_ID = "client_V1StGXR8_Z5jdHi6B-myT";
+
+describe("FeedSocketManager", () => {
+  let socket: Socket;
+  let manager: FeedSocketManager;
+  let mockChannel: Channel;
+  let mockFeed: Feed;
+
+  const createMockFeed = (overrides = {}) =>
+    ({
+      socketChannelTopic: "feeds:test:user1",
+      referenceId: TEST_CLIENT_REF_ID,
+      defaultOptions: { tenant: "test-tenant" },
+      handleSocketEvent: vi.fn(),
+      unsubscribeFromSocketEvents: vi.fn(),
+      socketManager: undefined,
+      userFeedId: "test-user-feed",
+      broadcaster: new EventEmitter(),
+      broadcastChannel: null,
+      disconnectTimer: null,
+      hasSubscribedToRealTimeUpdates: false,
+      visibilityChangeHandler: () => {},
+      visibilityChangeListenerConnected: false,
+      store: {} as StoreApi<unknown>,
+      knock: {} as Knock,
+      feedId: "test-feed",
+      ...overrides,
+    }) as unknown as Feed;
+
+  beforeEach(() => {
+    socket = new Socket("ws://localhost:4000/socket");
+    manager = new FeedSocketManager(socket);
+    mockChannel = {
+      join: vi.fn(),
+      leave: vi.fn(),
+      on: vi.fn(),
+      off: vi.fn(),
+      state: "closed",
+    } as unknown as Channel;
+    mockFeed = createMockFeed();
+
+    vi.spyOn(socket, "channel").mockReturnValue(mockChannel);
+    vi.spyOn(socket, "isConnected").mockReturnValue(false);
+    vi.spyOn(socket, "connect");
+  });
+
+  describe("join", () => {
+    it("should connect socket if not connected", () => {
+      manager.join(mockFeed);
+      expect(socket.connect).toHaveBeenCalled();
+    });
+
+    it("should create and join channel with correct params", () => {
+      manager.join(mockFeed);
+      expect(socket.channel).toHaveBeenCalledWith("feeds:test:user1", {
+        [TEST_CLIENT_REF_ID]: { tenant: "test-tenant" },
+      });
+      expect(mockChannel.join).toHaveBeenCalled();
+    });
+
+    it("should set up event listeners for socket events", () => {
+      manager.join(mockFeed);
+      expect(mockChannel.on).toHaveBeenCalledWith(
+        SocketEventType.NewMessage,
+        expect.any(Function),
+      );
+    });
+
+    it("should not rejoin channel if already joined", () => {
+      mockChannel.state = "joined";
+      manager.join(mockFeed);
+      expect(mockChannel.join).not.toHaveBeenCalled();
+    });
+
+    it("should update params and rejoin if params change", () => {
+      manager.join(mockFeed);
+      const newFeed = createMockFeed({
+        defaultOptions: { tenant: "new-tenant" },
+      });
+      manager.join(newFeed);
+      expect(socket.channel).toHaveBeenCalledWith("feeds:test:user1", {
+        [TEST_CLIENT_REF_ID]: { tenant: "new-tenant" },
+      });
+    });
+
+    it("should allow joining the same feed ID multiple times", () => {
+      manager.join(mockFeed);
+      const anotherFeed = createMockFeed({
+        referenceId: "client_IgJDCQHSh-C546bVSnATQ",
+        defaultOptions: { tenant: "test-tenant-2" },
+      });
+      manager.join(anotherFeed);
+
+      expect(socket.channel).toHaveBeenCalledTimes(2);
+      expect(mockChannel.join).toHaveBeenCalledTimes(2);
+
+      expect(socket.channel).toHaveBeenNthCalledWith(1, "feeds:test:user1", {
+        [TEST_CLIENT_REF_ID]: { tenant: "test-tenant" },
+      });
+      expect(socket.channel).toHaveBeenNthCalledWith(2, "feeds:test:user1", {
+        [TEST_CLIENT_REF_ID]: { tenant: "test-tenant" },
+        [anotherFeed.referenceId]: { tenant: "test-tenant-2" },
+      });
+    });
+  });
+
+  describe("leave", () => {
+    beforeEach(() => {
+      manager.join(mockFeed);
+    });
+
+    it("should unsubscribe feed from socket events", () => {
+      manager.leave(mockFeed);
+      expect(mockFeed.unsubscribeFromSocketEvents).toHaveBeenCalled();
+    });
+
+    it("should remove feed params and leave channel if no feeds remain", () => {
+      manager.leave(mockFeed);
+      expect(mockChannel.leave).toHaveBeenCalled();
+      expect(mockChannel.off).toHaveBeenCalledWith(SocketEventType.NewMessage);
+    });
+
+    it("should not leave channel if other feeds remain", () => {
+      const anotherFeed = createMockFeed({
+        referenceId: "client_IgJDCQHSh-C546bVSnATQ",
+      });
+      manager.join(anotherFeed);
+      manager.leave(mockFeed);
+      expect(mockChannel.leave).not.toHaveBeenCalled();
+    });
+  });
+
+  describe("socket event handling", () => {
+    it("should notify relevant feed clients when receiving socket events", () => {
+      const unsub = manager.join(mockFeed);
+      const eventHandler = vi.mocked(mockChannel.on).mock.calls[0]?.[1];
+
+      const payload = {
+        event: SocketEventType.NewMessage,
+        metadata: { id: "test" },
+        data: { TEST_CLIENT_REF_ID: { metadata: { id: "test" } } },
+        attn: [TEST_CLIENT_REF_ID],
+      };
+
+      eventHandler?.(payload);
+      expect(mockFeed.handleSocketEvent).toHaveBeenCalledWith({
+        event: SocketEventType.NewMessage,
+        metadata: { id: "test" },
+        data: { TEST_CLIENT_REF_ID: { metadata: { id: "test" } } },
+      });
+
+      unsub();
+    });
+
+    it("should not notify feed clients not in attn list", () => {
+      const anotherFeed = createMockFeed({
+        referenceId: "client_IgJDCQHSh-C546bVSnATQ",
+      });
+      manager.join(anotherFeed);
+      const eventHandler = vi.mocked(mockChannel.on).mock.calls[0]?.[1];
+
+      const payload = {
+        event: SocketEventType.NewMessage,
+        metadata: { id: "test" },
+        data: { TEST_CLIENT_REF_ID: { metadata: { id: "test" } } },
+        attn: [TEST_CLIENT_REF_ID],
+      };
+
+      eventHandler?.(payload);
+      expect(anotherFeed.handleSocketEvent).not.toHaveBeenCalled();
+    });
+  });
+});
diff --git a/yarn.lock b/yarn.lock
index 778033b58..acc0a0edc 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4854,6 +4854,7 @@ __metadata:
     eventemitter2: "npm:^6.4.5"
     jsonwebtoken: "npm:^9.0.2"
     jwt-decode: "npm:^4.0.0"
+    nanoid: "npm:^5.1.5"
     phoenix: "npm:1.7.19"
     prettier: "npm:^3.5.3"
     rimraf: "npm:^6.0.1"
@@ -16890,6 +16891,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"nanoid@npm:^5.1.5":
+  version: 5.1.5
+  resolution: "nanoid@npm:5.1.5"
+  bin:
+    nanoid: bin/nanoid.js
+  checksum: 10c0/e6004f1ad6c7123eeb037062c4441d44982037dc043aabb162457ef6986e99964ba98c63c975f96c547403beb0bf95bc537bd7bf9a09baf381656acdc2975c3c
+  languageName: node
+  linkType: hard
+
 "natural-compare@npm:^1.4.0":
   version: 1.4.0
   resolution: "natural-compare@npm:1.4.0"


Reviews:
user: login: mattmikolay
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T19:03:33Z
user: login: cjbell
body: an incredibly good improvement 👏 👏👏
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T19:38:26Z
user: login: connorlindsey
body: Amazing work digging into this!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T20:02:19Z
user: login: mattmikolay
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T14:30:54Z
user: login: cjbell
body: 💯💯💯 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T14:34:22Z

Review comments:
diff_hunk: @@ -860,7 +868,7 @@ class Feed {
 
       // If the socket is not connected, try to reconnect
       if (!client.socket?.isConnected()) {
-        this.initializeRealtimeConnection();
+        client.socket?.connect();
path: packages/client/src/clients/feed/feed.ts
user: login: mattmikolay
body: I’m changing this to just `client.socket?.connect()`, so that we don’t attempt to unnecessarily join the channel again. Doing so triggers [the following Phoenix error](https://github.com/phoenixframework/phoenix/blob/95860c252d14f573583ce09211352d26c64cbf86/assets/js/phoenix/channel.js#L81):

> tried to join multiple times. 'join' can only be called a single time per channel instance
created_at: 2025-05-02T19:03:33Z
author_association: CONTRIBUTOR
path: packages/client/src/clients/feed/socket-manager.ts
user: login: cjbell
body: shall we ask cursor to write unit tests for this while we're here?
created_at: 2025-05-02T19:37:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -767,36 +774,37 @@ class Feed {
   }
 
   private initializeRealtimeConnection() {
-    const { socket: maybeSocket } = this.knock.client();
-
     // In server environments we might not have a socket connection
-    if (!maybeSocket) return;
-
-    // Reinitialize channel connections incase the socket changed
-    this.channel = maybeSocket.channel(
-      `feeds:${this.userFeedId}`,
-      this.defaultOptions,
-    );
-
-    this.channel.on("new-message", (resp) => this.onNewMessageReceived(resp));
+    if (!this.socketManager) return;
 
     if (this.defaultOptions.auto_manage_socket_connection) {
-      this.setupAutoSocketManager();
+      this.setUpVisibilityListeners();
     }
 
     // If we're initializing but they have previously opted to listen to real-time updates
     // then we will automatically reconnect on their behalf
     if (this.hasSubscribedToRealTimeUpdates && this.knock.isAuthenticated()) {
-      if (!maybeSocket.isConnected()) maybeSocket.connect();
-      this.channel.join();
+      this.unsubscribeFromSocketEvents = this.socketManager?.join(this);
+    }
+  }
+
+  async handleSocketEvent(payload: SocketEventPayload) {
+    switch (payload.event) {
path: packages/client/src/clients/feed/feed.ts
user: login: cjbell
body: Lovely
created_at: 2025-05-02T19:38:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,9 @@
+---
+"@knocklabs/client": patch
+---
+
+Allow multiple instances of `Feed` to listen for real-time updates to the same notification feed
path: .changeset/major-moles-talk.md
user: login: connorlindsey
body: Appreciate the detailed PR description and changelog!
created_at: 2025-05-02T20:01:14Z
author_association: CONTRIBUTOR
path: packages/client/src/clients/feed/socket-manager.ts
user: login: mattmikolay
body: Done! Cursor actually did a decent job here.
created_at: 2025-05-05T14:30:54Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-05-02T13:32:44Z
body: [vc]: #mZv8tmklIY6QLJHTiyz372BhCemB8t5khKDko77O3wI=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJqYXZhc2NyaXB0LW5leHRqcy1leGFtcGxlIiwicm9vdERpcmVjdG9yeSI6ImV4YW1wbGVzL25leHRqcy1leGFtcGxlIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2tub2NrbGFicy9qYXZhc2NyaXB0LW5leHRqcy1leGFtcGxlLzZya0pocDRzeGFGdU1QRk1ETm1ERnFRUVRuOTIiLCJwcmV2aWV3VXJsIjoiamF2YXNjcmlwdC1uZXh0anMtZXhhbXBsZS1naXQtbWF0dG1pay1rbm8tODU0OC0wYjNlNzMta25vY2tsYWJzLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiamF2YXNjcmlwdC1uZXh0anMtZXhhbXBsZS1naXQtbWF0dG1pay1rbm8tODU0OC0wYjNlNzMta25vY2tsYWJzLnZlcmNlbC5hcHAifX0seyJuYW1lIjoiamF2YXNjcmlwdC1tcy10ZWFtcy1jb25uZWN0LWV4YW1wbGUiLCJyb290RGlyZWN0b3J5IjoiZXhhbXBsZXMvbXMtdGVhbXMtY29ubmVjdC1leGFtcGxlIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2tub2NrbGFicy9qYXZhc2NyaXB0LW1zLXRlYW1zLWNvbm5lY3QtZXhhbXBsZS9EOEJzTjFtdUwzNWF6elB6TGdEbTVDVWczOUhyIiwicHJldmlld1VybCI6ImphdmFzY3JpcHQtbXMtdGVhbXMtY29ubmVjdC1leGFtcGxlLWdpdC1tYXR0bWktNTdiNTIyLWtub2NrbGFicy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImphdmFzY3JpcHQtbXMtdGVhbXMtY29ubmVjdC1leGFtcGxlLWdpdC1tYXR0bWktNTdiNTIyLWtub2NrbGFicy52ZXJjZWwuYXBwIn19LHsibmFtZSI6ImphdmFzY3JpcHQtc2xhY2sta2l0LWV4YW1wbGUiLCJyb290RGlyZWN0b3J5IjoiZXhhbXBsZXMvc2xhY2sta2l0LWV4YW1wbGUiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20va25vY2tsYWJzL2phdmFzY3JpcHQtc2xhY2sta2l0LWV4YW1wbGUvR2JQYXNyYlRrNFo5ZFl6dnVFUWVaN2RvZHVmYyIsInByZXZpZXdVcmwiOiJqYXZhc2NyaXB0LXNsYWNrLWtpdC1leGFtcGxlLWdpdC1tYXR0bWlrLWtuby04LTFjZDcyYi1rbm9ja2xhYnMudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJqYXZhc2NyaXB0LXNsYWNrLWtpdC1leGFtcGxlLWdpdC1tYXR0bWlrLWtuby04LTFjZDcyYi1rbm9ja2xhYnMudmVyY2VsLmFwcCJ9fSx7Im5hbWUiOiJqYXZhc2NyaXB0LXNsYWNrLWNvbm5lY3QtZXhhbXBsZSIsInJvb3REaXJlY3RvcnkiOiJleGFtcGxlcy9zbGFjay1jb25uZWN0LWV4YW1wbGUiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20va25vY2tsYWJzL2phdmFzY3JpcHQtc2xhY2stY29ubmVjdC1leGFtcGxlLzYxaVNZTjVlVzgxMkQybjIyWFl2bzZGNkRvS1oiLCJwcmV2aWV3VXJsIjoiamF2YXNjcmlwdC1zbGFjay1jb25uZWN0LWV4YW1wbGUtZ2l0LW1hdHRtaWstay02NmEwNmMta25vY2tsYWJzLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiamF2YXNjcmlwdC1zbGFjay1jb25uZWN0LWV4YW1wbGUtZ2l0LW1hdHRtaWstay02NmEwNmMta25vY2tsYWJzLnZlcmNlbC5hcHAifX1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **javascript-ms-teams-connect-example** | ✅ Ready ([Inspect](https://vercel.com/knocklabs/javascript-ms-teams-connect-example/D8BsN1muL35azzPzLgDm5CUg39Hr)) | [Visit Preview](https://javascript-ms-teams-connect-example-git-mattmi-57b522-knocklabs.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/javascript-ms-teams-connect-example-git-mattmi-57b522-knocklabs.vercel.app?via=pr-comment-feedback-link) | May 5, 2025 4:11pm |
| **javascript-nextjs-example** | ✅ Ready ([Inspect](https://vercel.com/knocklabs/javascript-nextjs-example/6rkJhp4sxaFuMPFMDNmDFqQQTn92)) | [Visit Preview](https://javascript-nextjs-example-git-mattmik-kno-8548-0b3e73-knocklabs.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/javascript-nextjs-example-git-mattmik-kno-8548-0b3e73-knocklabs.vercel.app?via=pr-comment-feedback-link) | May 5, 2025 4:11pm |
| **javascript-slack-connect-example** | ✅ Ready ([Inspect](https://vercel.com/knocklabs/javascript-slack-connect-example/61iSYN5eW812D2n22XYvo6F6DoKZ)) | [Visit Preview](https://javascript-slack-connect-example-git-mattmik-k-66a06c-knocklabs.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/javascript-slack-connect-example-git-mattmik-k-66a06c-knocklabs.vercel.app?via=pr-comment-feedback-link) | May 5, 2025 4:11pm |
| **javascript-slack-kit-example** | ✅ Ready ([Inspect](https://vercel.com/knocklabs/javascript-slack-kit-example/GbPasrbTk4Z9dYzvuEQeZ7dodufc)) | [Visit Preview](https://javascript-slack-kit-example-git-mattmik-kno-8-1cd72b-knocklabs.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/javascript-slack-kit-example-git-mattmik-kno-8-1cd72b-knocklabs.vercel.app?via=pr-comment-feedback-link) | May 5, 2025 4:11pm |





author_association: NONE
user: login: linear[bot]
created_at: 2025-05-02T13:32:44Z
body: <p><a href="https://linear.app/knock/issue/KNO-8548/client-sdk-add-socket-manager-to-enable-use-of-multiple-feed-clients">KNO-8548 [Client SDK] Add socket manager to enable use of multiple feed clients</a></p>
author_association: NONE
user: login: changeset-bot[bot]
created_at: 2025-05-02T13:32:45Z
body: ###  🦋  Changeset detected

Latest commit: 9810c567bdfcc75dad412b236cf156ce33757e91

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 10 packages</summary>

  | Name                            | Type  |
| ------------------------------- | ----- |
| @knocklabs/client               | Patch |
| client-example                  | Patch |
| guide-example                   | Patch |
| @knocklabs/expo                 | Patch |
| @knocklabs/react-core           | Patch |
| @knocklabs/react-native         | Patch |
| @knocklabs/react                | Patch |
| @knocklabs/expo-example         | Patch |
| @knocklabs/react-native-example | Patch |
| nextjs-example                  | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/knocklabs/javascript/new/mattmik-kno-8548-client-sdk-add-socket-manager-to-enable-use-of-multiple-feed?filename=.changeset/weak-drinks-drum.md&value=---%0A%22%40knocklabs%2Fclient%22%3A%20patch%0A---%0A%0Afeat(KNO-8548)%3A%20add%20feed%20socket%20manager%0A)


author_association: NONE
user: login: mattmikolay
created_at: 2025-05-02T13:32:58Z
body: * **#513** <a href="https://app.graphite.dev/github/pr/knocklabs/javascript/513?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/knocklabs/javascript/513?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: CONTRIBUTOR