From 0c253fe61e8b1b676746a562364a7293d7d23649 Mon Sep 17 00:00:00 2001 From: Xavier Brochard Date: Tue, 29 Sep 2026 10:09:31 +0200 Subject: [PATCH 1/4] feat(social-controllers): add realtime feed service Co-authored-by: Cursor --- packages/social-controllers/CHANGELOG.md | 4 + packages/social-controllers/package.json | 1 + .../src/SocialRealtimeService.test.ts | 373 ++++++++++++++++++ .../src/SocialRealtimeService.ts | 195 +++++++++ packages/social-controllers/src/index.test.ts | 11 +- packages/social-controllers/src/index.ts | 11 + .../social-controllers/tsconfig.build.json | 3 + packages/social-controllers/tsconfig.json | 3 + .../social-controllers/tsconfig.lint.json | 3 + yarn.lock | 83 +++- 10 files changed, 680 insertions(+), 7 deletions(-) create mode 100644 packages/social-controllers/src/SocialRealtimeService.test.ts create mode 100644 packages/social-controllers/src/SocialRealtimeService.ts diff --git a/packages/social-controllers/CHANGELOG.md b/packages/social-controllers/CHANGELOG.md index 6d7226b3f33..f764e78ac77 100644 --- a/packages/social-controllers/CHANGELOG.md +++ b/packages/social-controllers/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `SocialRealtimeService` for subscribing to Social realtime feed events through the shared backend WebSocket connection ([#10561](https://github.com/MetaMask/core/pull/10561)) + ### Changed - Bump `@metamask/base-data-service` from `^2.0.0` to `^2.1.0` ([#10502](https://github.com/MetaMask/core/pull/10502)) diff --git a/packages/social-controllers/package.json b/packages/social-controllers/package.json index e07ff9532d5..47e7b1622c9 100644 --- a/packages/social-controllers/package.json +++ b/packages/social-controllers/package.json @@ -53,6 +53,7 @@ "@metamask/base-controller": "^10.0.0", "@metamask/base-data-service": "^2.1.0", "@metamask/controller-utils": "^13.0.0", + "@metamask/core-backend": "^10.0.1", "@metamask/messenger": "^3.0.0", "@metamask/profile-sync-controller": "^33.0.0", "@metamask/superstruct": "^3.4.1" diff --git a/packages/social-controllers/src/SocialRealtimeService.test.ts b/packages/social-controllers/src/SocialRealtimeService.test.ts new file mode 100644 index 00000000000..2d0689685fd --- /dev/null +++ b/packages/social-controllers/src/SocialRealtimeService.test.ts @@ -0,0 +1,373 @@ +import { WebSocketState } from '@metamask/core-backend'; +import type { + BackendWebSocketServiceMessenger, + WebSocketSubscription, +} from '@metamask/core-backend'; + +import { + SOCIAL_FEED_CHANNEL, + SOCIAL_FEED_CHANNEL_TYPE, + SocialRealtimeService, +} from './SocialRealtimeService.js'; + +describe('SocialRealtimeService', () => { + type ConnectionStateHandler = (connection: { state: WebSocketState }) => void; + + type BackendCall = jest.Mock; + + type TestHarness = { + call: BackendCall; + connectionStateHandler: () => ConnectionStateHandler | undefined; + messenger: BackendWebSocketServiceMessenger; + service: SocialRealtimeService; + unsubscribe: jest.Mock; + }; + + const event = { + version: 1 as const, + kind: 'feed-item' as const, + eventId: 'event-1', + feedItemId: 'position-1:trade-1', + revision: 1, + occurredAt: '2026-01-01T00:00:00.000Z', + data: { + positionId: 'position-1', + timestamp: 1, + }, + }; + + const createService = (enabled = true): TestHarness => { + let connectionStateHandler: ConnectionStateHandler | undefined; + const unsubscribe = jest.fn().mockResolvedValue(undefined); + const call: BackendCall = jest.fn( + (action: string, _options?: unknown): unknown => { + if (action === 'BackendWebSocketService:connect') { + return Promise.resolve(); + } + + if (action === 'BackendWebSocketService:channelHasSubscription') { + return false; + } + + if (action === 'BackendWebSocketService:getSubscriptionsByChannel') { + return []; + } + + return Promise.resolve({ unsubscribe }); + }, + ); + const messenger = { + call, + subscribe: jest.fn( + (_event: string, handler: ConnectionStateHandler): void => { + connectionStateHandler = handler; + }, + ), + }; + const service = new SocialRealtimeService({ + messenger: messenger as unknown as BackendWebSocketServiceMessenger, + isEnabled: (): boolean => enabled, + }); + + return { + call, + connectionStateHandler: (): ConnectionStateHandler | undefined => + connectionStateHandler, + messenger: messenger as unknown as BackendWebSocketServiceMessenger, + service, + unsubscribe, + }; + }; + + const getFeedCallback = ( + call: BackendCall, + ): ((notification: { channel: string; data: unknown }) => void) => { + const subscribeCall = call.mock.calls.find( + ([action]) => action === 'BackendWebSocketService:subscribe', + ); + if (!subscribeCall) { + throw new Error('The feed subscription was not created'); + } + + const [, subscribeOptions] = subscribeCall; + return ( + subscribeOptions as { + callback: (notification: { channel: string; data: unknown }) => void; + } + ).callback; + }; + + it('subscribes and forwards valid feed events', async () => { + const { call, service } = createService(); + const listener = jest.fn(); + service.addListener(listener); + + await service.setActive(true); + const handleNotification = getFeedCallback(call); + handleNotification({ + channel: SOCIAL_FEED_CHANNEL, + data: event, + }); + + expect(call).toHaveBeenCalledWith('BackendWebSocketService:connect'); + expect(call).toHaveBeenCalledWith( + 'BackendWebSocketService:subscribe', + expect.objectContaining({ + channels: [SOCIAL_FEED_CHANNEL], + channelType: SOCIAL_FEED_CHANNEL_TYPE, + }), + ); + expect(listener).toHaveBeenCalledWith(event); + }); + + it.each([ + ['version', { version: 2 }], + ['kind', { kind: 'other' }], + ['eventId', { eventId: 1 }], + ['feedItemId', { feedItemId: 1 }], + ['revision', { revision: '1' }], + ['occurredAt', { occurredAt: 1 }], + ['data', { data: null }], + ])( + 'ignores feed events with an invalid %s field', + async (_field, invalid) => { + const { call, service } = createService(); + const listener = jest.fn(); + service.addListener(listener); + + await service.setActive(true); + getFeedCallback(call)({ + channel: SOCIAL_FEED_CHANNEL, + data: { ...event, ...invalid }, + }); + + expect(listener).not.toHaveBeenCalled(); + }, + ); + + it('ignores notifications for other channels', async () => { + const { call, service } = createService(); + const listener = jest.fn(); + service.addListener(listener); + + await service.setActive(true); + getFeedCallback(call)({ + channel: 'social.v1.other', + data: event, + }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('keeps the feed unsubscribed while the enablement gate is disabled', async () => { + const { call, service } = createService(false); + + await service.setActive(true); + + expect(call).not.toHaveBeenCalled(); + }); + + it('enables the feed by default when no gate is provided', async () => { + const { call, messenger } = createService(); + const service = new SocialRealtimeService({ messenger }); + + await service.setActive(true); + + expect(call).toHaveBeenCalledWith( + 'BackendWebSocketService:subscribe', + expect.objectContaining({ + channels: [SOCIAL_FEED_CHANNEL], + }), + ); + }); + + it('reuses an existing backend subscription for the feed channel', async () => { + const { call, service, unsubscribe } = createService(); + const existingSubscription = { + unsubscribe, + } as unknown as WebSocketSubscription; + call.mockImplementation((action: string, _options?: unknown): unknown => { + if (action === 'BackendWebSocketService:connect') { + return Promise.resolve(); + } + + if (action === 'BackendWebSocketService:channelHasSubscription') { + return true; + } + + if (action === 'BackendWebSocketService:getSubscriptionsByChannel') { + return [existingSubscription]; + } + + return Promise.resolve({ unsubscribe }); + }); + + await service.setActive(true); + await service.setActive(false); + + expect(call).not.toHaveBeenCalledWith( + 'BackendWebSocketService:subscribe', + expect.anything(), + ); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it('does not retain an empty backend subscription lookup', async () => { + const { call, service, unsubscribe } = createService(); + call.mockImplementation((action: string, _options?: unknown): unknown => { + if (action === 'BackendWebSocketService:connect') { + return Promise.resolve(); + } + + if (action === 'BackendWebSocketService:channelHasSubscription') { + return true; + } + + if (action === 'BackendWebSocketService:getSubscriptionsByChannel') { + return []; + } + + return Promise.resolve({ unsubscribe }); + }); + + await service.setActive(true); + await service.setActive(false); + + expect(unsubscribe).not.toHaveBeenCalled(); + }); + + it('avoids creating a second subscription while already active', async () => { + const { call, service } = createService(); + + await service.setActive(true); + await service.setActive(true); + + expect( + call.mock.calls.filter( + ([action]) => action === 'BackendWebSocketService:subscribe', + ), + ).toHaveLength(1); + }); + + it('ignores an initial connected notification instead of reporting a reconnect', async () => { + const { call, connectionStateHandler, service } = createService(); + const listener = jest.fn(); + service.addReconnectListener(listener); + + await service.setActive(true); + connectionStateHandler()?.({ state: WebSocketState.CONNECTED }); + await Promise.resolve(); + + expect(listener).not.toHaveBeenCalled(); + expect( + call.mock.calls.filter( + ([action]) => action === 'BackendWebSocketService:subscribe', + ), + ).toHaveLength(1); + }); + + it('unsubscribes a subscription that resolves after deactivation', async () => { + const { call, service, unsubscribe } = createService(); + let resolveSubscription: (value: WebSocketSubscription) => void = () => + undefined; + const subscriptionPromise = new Promise( + (resolve): void => { + resolveSubscription = resolve; + }, + ); + call.mockImplementation((action: string, _options?: unknown): unknown => { + if (action === 'BackendWebSocketService:connect') { + return Promise.resolve(); + } + + if (action === 'BackendWebSocketService:channelHasSubscription') { + return false; + } + + if (action === 'BackendWebSocketService:getSubscriptionsByChannel') { + return []; + } + + return subscriptionPromise; + }); + + const activation = service.setActive(true); + await Promise.resolve(); + await Promise.resolve(); + await service.setActive(false); + resolveSubscription({ unsubscribe } as unknown as WebSocketSubscription); + await activation; + + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it('swallows subscription errors so feed activation remains best effort', async () => { + const { call, service } = createService(); + const error = new Error('WebSocket unavailable'); + call.mockImplementation((action: string, _options?: unknown): unknown => { + if (action === 'BackendWebSocketService:connect') { + return Promise.reject(error); + } + + return Promise.resolve({ unsubscribe: jest.fn() }); + }); + + await service.setActive(true); + + expect(call).toHaveBeenCalledWith('BackendWebSocketService:connect'); + }); + + it('does not unsubscribe when deactivated without an active subscription', async () => { + const { service, unsubscribe } = createService(); + + await service.setActive(false); + + expect(unsubscribe).not.toHaveBeenCalled(); + }); + + it('removes feed and reconnect listeners', () => { + const { service } = createService(); + const removeFeedListener = service.addListener(jest.fn()); + const removeReconnectListener = service.addReconnectListener(jest.fn()); + + expect(removeFeedListener()).toBe(true); + expect(removeReconnectListener()).toBe(true); + }); + + it('resubscribes after the shared socket reconnects', async () => { + const { call, connectionStateHandler, service } = createService(); + + await service.setActive(true); + connectionStateHandler()?.({ state: WebSocketState.DISCONNECTED }); + connectionStateHandler()?.({ state: WebSocketState.CONNECTED }); + await Promise.resolve(); + await Promise.resolve(); + + expect( + call.mock.calls.filter( + ([action]) => action === 'BackendWebSocketService:subscribe', + ), + ).toHaveLength(2); + }); + + it('notifies active feed listeners after a socket reconnects', async () => { + const { connectionStateHandler, service } = createService(); + const listener = jest.fn(); + service.addReconnectListener(listener); + + await service.setActive(true); + connectionStateHandler()?.({ state: WebSocketState.DISCONNECTED }); + connectionStateHandler()?.({ state: WebSocketState.CONNECTED }); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('unsubscribes when the feed leaves the active tab', async () => { + const { service, unsubscribe } = createService(); + + await service.setActive(true); + await service.setActive(false); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/social-controllers/src/SocialRealtimeService.ts b/packages/social-controllers/src/SocialRealtimeService.ts new file mode 100644 index 00000000000..71ab6b05a18 --- /dev/null +++ b/packages/social-controllers/src/SocialRealtimeService.ts @@ -0,0 +1,195 @@ +import { WebSocketState } from '@metamask/core-backend'; +import type { + BackendWebSocketServiceMessenger, + ServerNotificationMessage, + WebSocketSubscription, +} from '@metamask/core-backend'; + +import type { FeedItem } from './social-types.js'; + +export const SOCIAL_FEED_CHANNEL = 'social.v1.feed.all' as const; +export const SOCIAL_FEED_CHANNEL_TYPE = 'social.v1' as const; + +export type SocialFeedEvent = { + version: 1; + kind: 'feed-item'; + eventId: string; + feedItemId: string; + revision: number; + occurredAt: string; + data: FeedItem; +}; + +export type SocialFeedEventListener = (event: SocialFeedEvent) => void; +export type SocialFeedReconnectListener = () => void; + +export type SocialRealtimeServiceOptions = { + messenger: BackendWebSocketServiceMessenger; + isEnabled?: () => boolean; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const parseSocialFeedEvent = ( + data: Record, +): SocialFeedEvent | undefined => { + if ( + data.version !== 1 || + data.kind !== 'feed-item' || + typeof data.eventId !== 'string' || + typeof data.feedItemId !== 'string' || + typeof data.revision !== 'number' || + typeof data.occurredAt !== 'string' || + !isRecord(data.data) + ) { + return undefined; + } + + return data as unknown as SocialFeedEvent; +}; + +/** + * Social realtime subscription service built on the shared backend WebSocket. + * + * The POC subscribes to the global feed channel. Additional Social channels + * can be added here without creating another WebSocket connection. + */ +export class SocialRealtimeService { + readonly name = 'SocialRealtimeService' as const; + + readonly #messenger: BackendWebSocketServiceMessenger; + + readonly #listeners = new Set(); + + readonly #reconnectListeners = new Set(); + + readonly #isEnabled: () => boolean; + + #active = false; + + #subscription: WebSocketSubscription | undefined; + + #wasDisconnected = false; + + constructor({ + messenger, + isEnabled = (): boolean => true, + }: SocialRealtimeServiceOptions) { + this.#messenger = messenger; + this.#isEnabled = isEnabled; + + this.#messenger.subscribe( + 'BackendWebSocketService:connectionStateChanged', + ({ state }: { state: WebSocketState }): void => { + if (state === WebSocketState.DISCONNECTED) { + this.#subscription = undefined; + this.#wasDisconnected = true; + } + + if (state === WebSocketState.CONNECTED && this.#active) { + const isReconnect = this.#wasDisconnected; + this.#wasDisconnected = false; + // Subscription setup handles its own errors and must not block reconnect handling. + // eslint-disable-next-line no-void + void this.#subscribeToFeed(); + + if (isReconnect) { + this.#reconnectListeners.forEach((listener) => listener()); + } + } + }, + ); + } + + addListener(listener: SocialFeedEventListener): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + addReconnectListener(listener: SocialFeedReconnectListener): () => void { + this.#reconnectListeners.add(listener); + return () => this.#reconnectListeners.delete(listener); + } + + async setActive(active: boolean): Promise { + this.#active = active && this.#isEnabled(); + + if (!this.#active) { + await this.#unsubscribeFromFeed(); + return; + } + + await this.#subscribeToFeed(); + } + + async #subscribeToFeed(): Promise { + if (!this.#active || this.#subscription) { + return; + } + + try { + await this.#messenger.call('BackendWebSocketService:connect'); + + if ( + this.#messenger.call( + 'BackendWebSocketService:channelHasSubscription', + SOCIAL_FEED_CHANNEL, + ) + ) { + const existingSubscription = this.#messenger.call( + 'BackendWebSocketService:getSubscriptionsByChannel', + SOCIAL_FEED_CHANNEL, + )[0]; + + if (existingSubscription) { + this.#subscription = existingSubscription; + } + + return; + } + + const subscription = await this.#messenger.call( + 'BackendWebSocketService:subscribe', + { + channels: [SOCIAL_FEED_CHANNEL], + channelType: SOCIAL_FEED_CHANNEL_TYPE, + callback: (notification: ServerNotificationMessage) => { + this.#handleNotification(notification); + }, + }, + ); + + if (!this.#active) { + await subscription.unsubscribe(); + return; + } + + this.#subscription = subscription; + } catch { + this.#subscription = undefined; + } + } + + async #unsubscribeFromFeed(): Promise { + const subscription = this.#subscription; + this.#subscription = undefined; + + if (subscription) { + await subscription.unsubscribe(); + } + } + + #handleNotification(notification: ServerNotificationMessage): void { + if (notification.channel !== SOCIAL_FEED_CHANNEL) { + return; + } + + const event = parseSocialFeedEvent(notification.data); + if (!event) { + return; + } + + this.#listeners.forEach((listener) => listener(event)); + } +} diff --git a/packages/social-controllers/src/index.test.ts b/packages/social-controllers/src/index.test.ts index 21e4fc8b486..a8a01e14ce8 100644 --- a/packages/social-controllers/src/index.test.ts +++ b/packages/social-controllers/src/index.test.ts @@ -1,4 +1,9 @@ -import { SocialController, SocialService, socialServiceName } from './index.js'; +import { + SocialController, + SocialRealtimeService, + SocialService, + socialServiceName, +} from './index.js'; describe('social-controllers exports', () => { it('exports SocialController class', () => { @@ -9,6 +14,10 @@ describe('social-controllers exports', () => { expect(SocialService).toBeDefined(); }); + it('exports SocialRealtimeService class', () => { + expect(SocialRealtimeService).toBeDefined(); + }); + it('exports serviceName', () => { expect(socialServiceName).toBe('SocialService'); }); diff --git a/packages/social-controllers/src/index.ts b/packages/social-controllers/src/index.ts index 60a7ae708c9..213c9222cd4 100644 --- a/packages/social-controllers/src/index.ts +++ b/packages/social-controllers/src/index.ts @@ -49,6 +49,17 @@ export type { } from './SocialService-method-action-types.js'; export { TRADER_RANKING_TAGS, TradeStruct } from './social-types.js'; +export { + SOCIAL_FEED_CHANNEL, + SOCIAL_FEED_CHANNEL_TYPE, + SocialRealtimeService, +} from './SocialRealtimeService.js'; +export type { + SocialFeedEvent, + SocialFeedEventListener, + SocialFeedReconnectListener, + SocialRealtimeServiceOptions, +} from './SocialRealtimeService.js'; export type { AuthorComment, CommentEngagement, diff --git a/packages/social-controllers/tsconfig.build.json b/packages/social-controllers/tsconfig.build.json index 9325a144097..03b2b1ef87e 100644 --- a/packages/social-controllers/tsconfig.build.json +++ b/packages/social-controllers/tsconfig.build.json @@ -14,6 +14,9 @@ { "path": "../controller-utils/tsconfig.build.json" }, + { + "path": "../core-backend/tsconfig.build.json" + }, { "path": "../messenger/tsconfig.build.json" }, diff --git a/packages/social-controllers/tsconfig.json b/packages/social-controllers/tsconfig.json index faef108f266..b94e511e23a 100644 --- a/packages/social-controllers/tsconfig.json +++ b/packages/social-controllers/tsconfig.json @@ -10,6 +10,9 @@ { "path": "../controller-utils" }, + { + "path": "../core-backend" + }, { "path": "../messenger" }, diff --git a/packages/social-controllers/tsconfig.lint.json b/packages/social-controllers/tsconfig.lint.json index cf316f6a069..e49684e691d 100644 --- a/packages/social-controllers/tsconfig.lint.json +++ b/packages/social-controllers/tsconfig.lint.json @@ -17,6 +17,9 @@ { "path": "../controller-utils/tsconfig.lint.json" }, + { + "path": "../core-backend/tsconfig.lint.json" + }, { "path": "../profile-sync-controller/tsconfig.lint.json" } diff --git a/yarn.lock b/yarn.lock index b837acb372a..3f2bccc9169 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5444,6 +5444,31 @@ __metadata: languageName: node linkType: hard +"@metamask/account-tree-controller@npm:^10.0.1": + version: 10.0.1 + resolution: "@metamask/account-tree-controller@npm:10.0.1" + dependencies: + "@metamask/accounts-controller": "npm:^40.0.0" + "@metamask/base-controller": "npm:^10.0.0" + "@metamask/keyring-api": "npm:^24.0.0" + "@metamask/keyring-controller": "npm:^28.0.0" + "@metamask/messenger": "npm:^3.0.0" + "@metamask/multichain-account-service": "npm:^14.0.0" + "@metamask/profile-sync-controller": "npm:^32.0.0" + "@metamask/snaps-controllers": "npm:^19.0.0" + "@metamask/snaps-sdk": "npm:^11.0.0" + "@metamask/snaps-utils": "npm:^12.1.2" + "@metamask/superstruct": "npm:^3.4.1" + "@metamask/utils": "npm:^11.12.0" + fast-deep-equal: "npm:^3.1.3" + lodash-es: "npm:^4.17.21" + peerDependencies: + "@metamask/providers": ^22.0.0 + webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 + checksum: 10/54f21e4473eef528ab990130ae3a700d0578b30132a8ece363704ee5232757bd2c196d05dfba8543cfe3572d1fd6177d51493d9f1cc6e9f7439f9ce31e486205 + languageName: node + linkType: hard + "@metamask/account-tree-controller@npm:^11.0.0, @metamask/account-tree-controller@workspace:packages/account-tree-controller": version: 0.0.0-use.local resolution: "@metamask/account-tree-controller@workspace:packages/account-tree-controller" @@ -6442,6 +6467,25 @@ __metadata: languageName: unknown linkType: soft +"@metamask/core-backend@npm:^10.0.1": + version: 10.0.1 + resolution: "@metamask/core-backend@npm:10.0.1" + dependencies: + "@metamask/account-tree-controller": "npm:^10.0.1" + "@metamask/controller-utils": "npm:^13.0.0" + "@metamask/keyring-controller": "npm:^28.0.0" + "@metamask/messenger": "npm:^3.0.0" + "@metamask/profile-sync-controller": "npm:^32.0.0" + "@metamask/remote-feature-flag-controller": "npm:^7.0.0" + "@metamask/utils": "npm:^11.12.0" + "@tanstack/query-core": "npm:^5.62.16" + async-mutex: "npm:^0.5.0" + cockatiel: "npm:^3.1.2" + uuid: "npm:^9.0.1" + checksum: 10/d9f47284415936d0e1cc25016e1fff9e25eee93c742c189391a0232c5697654275fc677ba0e6d5ff5899fa58c9559e51bf504d5e59d7674cf4b3b914a560e6bc + languageName: node + linkType: hard + "@metamask/core-backend@npm:^11.0.0, @metamask/core-backend@workspace:packages/core-backend": version: 0.0.0-use.local resolution: "@metamask/core-backend@workspace:packages/core-backend" @@ -7292,7 +7336,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-controller@npm:^28.1.0, @metamask/keyring-controller@workspace:packages/keyring-controller": +"@metamask/keyring-controller@npm:^28.0.0, @metamask/keyring-controller@npm:^28.1.0, @metamask/keyring-controller@workspace:packages/keyring-controller": version: 0.0.0-use.local resolution: "@metamask/keyring-controller@workspace:packages/keyring-controller" dependencies: @@ -7733,7 +7777,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/multichain-account-service@npm:^14.1.0, @metamask/multichain-account-service@workspace:packages/multichain-account-service": +"@metamask/multichain-account-service@npm:^14.0.0, @metamask/multichain-account-service@npm:^14.1.0, @metamask/multichain-account-service@workspace:packages/multichain-account-service": version: 0.0.0-use.local resolution: "@metamask/multichain-account-service@workspace:packages/multichain-account-service" dependencies: @@ -8465,6 +8509,32 @@ __metadata: languageName: unknown linkType: soft +"@metamask/profile-sync-controller@npm:^32.0.0": + version: 32.3.1 + resolution: "@metamask/profile-sync-controller@npm:32.3.1" + dependencies: + "@metamask/address-book-controller": "npm:^8.0.0" + "@metamask/base-controller": "npm:^10.0.0" + "@metamask/controller-utils": "npm:^13.0.0" + "@metamask/key-tree": "npm:^10.1.1" + "@metamask/keyring-controller": "npm:^28.1.0" + "@metamask/messenger": "npm:^3.0.0" + "@metamask/seedless-onboarding-controller": "npm:^11.0.0" + "@metamask/superstruct": "npm:^3.4.1" + "@metamask/utils": "npm:^12.0.0" + "@noble/ciphers": "npm:^1.3.0" + "@noble/curves": "npm:^1.9.2" + "@noble/hashes": "npm:^1.8.0" + "@signinwithethereum/siwe": "npm:^4.2.1" + immer: "npm:^9.0.21" + loglevel: "npm:^1.8.1" + peerDependencies: + "@metamask/providers": ^22.0.0 + webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 + checksum: 10/bf591e1d30ec84a5d9e3a1acb88f93b682cf0bf075534c580bb5498fa7ff275dd3af83ff9d010d6aafe88b4eee39c83279c3ce79f552178706b5e80c74b7e46c + languageName: node + linkType: hard + "@metamask/profile-sync-controller@npm:^33.0.0, @metamask/profile-sync-controller@workspace:packages/profile-sync-controller": version: 0.0.0-use.local resolution: "@metamask/profile-sync-controller@workspace:packages/profile-sync-controller" @@ -8694,7 +8764,7 @@ __metadata: languageName: node linkType: hard -"@metamask/seedless-onboarding-controller@npm:^11.0.1, @metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller": +"@metamask/seedless-onboarding-controller@npm:^11.0.0, @metamask/seedless-onboarding-controller@npm:^11.0.1, @metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller": version: 0.0.0-use.local resolution: "@metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller" dependencies: @@ -9057,6 +9127,7 @@ __metadata: "@metamask/base-controller": "npm:^10.0.0" "@metamask/base-data-service": "npm:^2.1.0" "@metamask/controller-utils": "npm:^13.0.0" + "@metamask/core-backend": "npm:^10.0.1" "@metamask/messenger": "npm:^3.0.0" "@metamask/profile-sync-controller": "npm:^33.0.0" "@metamask/superstruct": "npm:^3.4.1" @@ -9357,7 +9428,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.10.0, @metamask/utils@npm:^11.11.0, @metamask/utils@npm:^11.4.0, @metamask/utils@npm:^11.4.2, @metamask/utils@npm:^11.8.1, @metamask/utils@npm:^11.9.0": +"@metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.10.0, @metamask/utils@npm:^11.11.0, @metamask/utils@npm:^11.12.0, @metamask/utils@npm:^11.4.0, @metamask/utils@npm:^11.4.2, @metamask/utils@npm:^11.8.1, @metamask/utils@npm:^11.9.0": version: 11.12.1 resolution: "@metamask/utils@npm:11.12.1" dependencies: @@ -11608,7 +11679,7 @@ __metadata: languageName: node linkType: hard -"@tanstack/query-core@npm:5.103.2, @tanstack/query-core@npm:^5.103.2": +"@tanstack/query-core@npm:5.103.2, @tanstack/query-core@npm:^5.103.2, @tanstack/query-core@npm:^5.62.16": version: 5.103.2 resolution: "@tanstack/query-core@npm:5.103.2" checksum: 10/f19ea502e50fd4d0241fc6796d9303637666ce139b751c875eb9f1680278f20bce2dc95d9fbab30f3a997a456e02f9760e6ce9e4e58db71e256591e77bbc65c9 @@ -20561,7 +20632,7 @@ __metadata: languageName: node linkType: hard -"lodash-es@npm:^4.18.1": +"lodash-es@npm:^4.17.21, lodash-es@npm:^4.18.1": version: 4.18.1 resolution: "lodash-es@npm:4.18.1" checksum: 10/8bfad225ef09ef42b04283cdaf7830efcc2ba29ae41b56501c74422155ee1ccaa1f0f6e8319def3451a1fe54dec501c8e4bee622bae2b2d98ac993731e0a5cce From 2f8a7e493d6cc84dbdb7023906dc2ba4d9d2bcd6 Mon Sep 17 00:00:00 2001 From: Xavier Brochard Date: Tue, 29 Sep 2026 10:18:46 +0200 Subject: [PATCH 2/4] fix(social-controllers): protect realtime subscriptions Co-authored-by: Cursor --- packages/social-controllers/package.json | 2 +- .../src/SocialRealtimeService.test.ts | 59 +++++++++++-- .../src/SocialRealtimeService.ts | 77 +++++++++-------- yarn.lock | 84 ++----------------- 4 files changed, 101 insertions(+), 121 deletions(-) diff --git a/packages/social-controllers/package.json b/packages/social-controllers/package.json index 47e7b1622c9..6d8da6ce15b 100644 --- a/packages/social-controllers/package.json +++ b/packages/social-controllers/package.json @@ -53,7 +53,7 @@ "@metamask/base-controller": "^10.0.0", "@metamask/base-data-service": "^2.1.0", "@metamask/controller-utils": "^13.0.0", - "@metamask/core-backend": "^10.0.1", + "@metamask/core-backend": "^11.0.0", "@metamask/messenger": "^3.0.0", "@metamask/profile-sync-controller": "^33.0.0", "@metamask/superstruct": "^3.4.1" diff --git a/packages/social-controllers/src/SocialRealtimeService.test.ts b/packages/social-controllers/src/SocialRealtimeService.test.ts index 2d0689685fd..b752efed0ab 100644 --- a/packages/social-controllers/src/SocialRealtimeService.test.ts +++ b/packages/social-controllers/src/SocialRealtimeService.test.ts @@ -181,10 +181,12 @@ describe('SocialRealtimeService', () => { ); }); - it('reuses an existing backend subscription for the feed channel', async () => { + it('creates an owned subscription when the feed channel already has one', async () => { const { call, service, unsubscribe } = createService(); + const listener = jest.fn(); + const existingUnsubscribe = jest.fn().mockResolvedValue(undefined); const existingSubscription = { - unsubscribe, + unsubscribe: existingUnsubscribe, } as unknown as WebSocketSubscription; call.mockImplementation((action: string, _options?: unknown): unknown => { if (action === 'BackendWebSocketService:connect') { @@ -202,17 +204,26 @@ describe('SocialRealtimeService', () => { return Promise.resolve({ unsubscribe }); }); + service.addListener(listener); await service.setActive(true); + getFeedCallback(call)({ + channel: SOCIAL_FEED_CHANNEL, + data: event, + }); await service.setActive(false); - expect(call).not.toHaveBeenCalledWith( + expect(call).toHaveBeenCalledWith( 'BackendWebSocketService:subscribe', - expect.anything(), + expect.objectContaining({ + channels: [SOCIAL_FEED_CHANNEL], + }), ); + expect(listener).toHaveBeenCalledWith(event); + expect(existingUnsubscribe).not.toHaveBeenCalled(); expect(unsubscribe).toHaveBeenCalledTimes(1); }); - it('does not retain an empty backend subscription lookup', async () => { + it('creates a subscription when the backend lookup is empty', async () => { const { call, service, unsubscribe } = createService(); call.mockImplementation((action: string, _options?: unknown): unknown => { if (action === 'BackendWebSocketService:connect') { @@ -233,7 +244,13 @@ describe('SocialRealtimeService', () => { await service.setActive(true); await service.setActive(false); - expect(unsubscribe).not.toHaveBeenCalled(); + expect(call).toHaveBeenCalledWith( + 'BackendWebSocketService:subscribe', + expect.objectContaining({ + channels: [SOCIAL_FEED_CHANNEL], + }), + ); + expect(unsubscribe).toHaveBeenCalledTimes(1); }); it('avoids creating a second subscription while already active', async () => { @@ -249,6 +266,36 @@ describe('SocialRealtimeService', () => { ).toHaveLength(1); }); + it('avoids duplicate subscriptions when connect reports connected synchronously', async () => { + const { call, connectionStateHandler, service, unsubscribe } = + createService(); + let hasReportedConnected = false; + call.mockImplementation((action: string, _options?: unknown): unknown => { + if (action === 'BackendWebSocketService:connect') { + if (!hasReportedConnected) { + hasReportedConnected = true; + connectionStateHandler()?.({ state: WebSocketState.CONNECTED }); + } + return Promise.resolve(); + } + + if (action === 'BackendWebSocketService:channelHasSubscription') { + return false; + } + + return Promise.resolve({ unsubscribe }); + }); + + await service.setActive(true); + await Promise.resolve(); + + expect( + call.mock.calls.filter( + ([action]) => action === 'BackendWebSocketService:subscribe', + ), + ).toHaveLength(1); + }); + it('ignores an initial connected notification instead of reporting a reconnect', async () => { const { call, connectionStateHandler, service } = createService(); const listener = jest.fn(); diff --git a/packages/social-controllers/src/SocialRealtimeService.ts b/packages/social-controllers/src/SocialRealtimeService.ts index 71ab6b05a18..9289e9c8060 100644 --- a/packages/social-controllers/src/SocialRealtimeService.ts +++ b/packages/social-controllers/src/SocialRealtimeService.ts @@ -70,6 +70,8 @@ export class SocialRealtimeService { #subscription: WebSocketSubscription | undefined; + #subscriptionPromise: Promise | undefined; + #wasDisconnected = false; constructor({ @@ -128,46 +130,47 @@ export class SocialRealtimeService { return; } - try { - await this.#messenger.call('BackendWebSocketService:connect'); - - if ( - this.#messenger.call( - 'BackendWebSocketService:channelHasSubscription', - SOCIAL_FEED_CHANNEL, - ) - ) { - const existingSubscription = this.#messenger.call( - 'BackendWebSocketService:getSubscriptionsByChannel', - SOCIAL_FEED_CHANNEL, - )[0]; - - if (existingSubscription) { - this.#subscription = existingSubscription; - } + if (this.#subscriptionPromise) { + await this.#subscriptionPromise; + return; + } - return; - } + const subscriptionPromise = Promise.resolve().then( + async (): Promise => { + try { + await this.#messenger.call('BackendWebSocketService:connect'); + + // Each service needs its own callback and subscription lifecycle. + const subscription = await this.#messenger.call( + 'BackendWebSocketService:subscribe', + { + channels: [SOCIAL_FEED_CHANNEL], + channelType: SOCIAL_FEED_CHANNEL_TYPE, + callback: (notification: ServerNotificationMessage) => { + this.#handleNotification(notification); + }, + }, + ); + + if (!this.#active) { + await subscription.unsubscribe(); + return; + } - const subscription = await this.#messenger.call( - 'BackendWebSocketService:subscribe', - { - channels: [SOCIAL_FEED_CHANNEL], - channelType: SOCIAL_FEED_CHANNEL_TYPE, - callback: (notification: ServerNotificationMessage) => { - this.#handleNotification(notification); - }, - }, - ); - - if (!this.#active) { - await subscription.unsubscribe(); - return; - } + this.#subscription = subscription; + } catch { + this.#subscription = undefined; + } + }, + ); + this.#subscriptionPromise = subscriptionPromise; - this.#subscription = subscription; - } catch { - this.#subscription = undefined; + try { + await subscriptionPromise; + } finally { + if (this.#subscriptionPromise === subscriptionPromise) { + this.#subscriptionPromise = undefined; + } } } diff --git a/yarn.lock b/yarn.lock index 3f2bccc9169..f88bfde113c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5444,31 +5444,6 @@ __metadata: languageName: node linkType: hard -"@metamask/account-tree-controller@npm:^10.0.1": - version: 10.0.1 - resolution: "@metamask/account-tree-controller@npm:10.0.1" - dependencies: - "@metamask/accounts-controller": "npm:^40.0.0" - "@metamask/base-controller": "npm:^10.0.0" - "@metamask/keyring-api": "npm:^24.0.0" - "@metamask/keyring-controller": "npm:^28.0.0" - "@metamask/messenger": "npm:^3.0.0" - "@metamask/multichain-account-service": "npm:^14.0.0" - "@metamask/profile-sync-controller": "npm:^32.0.0" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/superstruct": "npm:^3.4.1" - "@metamask/utils": "npm:^11.12.0" - fast-deep-equal: "npm:^3.1.3" - lodash-es: "npm:^4.17.21" - peerDependencies: - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/54f21e4473eef528ab990130ae3a700d0578b30132a8ece363704ee5232757bd2c196d05dfba8543cfe3572d1fd6177d51493d9f1cc6e9f7439f9ce31e486205 - languageName: node - linkType: hard - "@metamask/account-tree-controller@npm:^11.0.0, @metamask/account-tree-controller@workspace:packages/account-tree-controller": version: 0.0.0-use.local resolution: "@metamask/account-tree-controller@workspace:packages/account-tree-controller" @@ -6467,25 +6442,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/core-backend@npm:^10.0.1": - version: 10.0.1 - resolution: "@metamask/core-backend@npm:10.0.1" - dependencies: - "@metamask/account-tree-controller": "npm:^10.0.1" - "@metamask/controller-utils": "npm:^13.0.0" - "@metamask/keyring-controller": "npm:^28.0.0" - "@metamask/messenger": "npm:^3.0.0" - "@metamask/profile-sync-controller": "npm:^32.0.0" - "@metamask/remote-feature-flag-controller": "npm:^7.0.0" - "@metamask/utils": "npm:^11.12.0" - "@tanstack/query-core": "npm:^5.62.16" - async-mutex: "npm:^0.5.0" - cockatiel: "npm:^3.1.2" - uuid: "npm:^9.0.1" - checksum: 10/d9f47284415936d0e1cc25016e1fff9e25eee93c742c189391a0232c5697654275fc677ba0e6d5ff5899fa58c9559e51bf504d5e59d7674cf4b3b914a560e6bc - languageName: node - linkType: hard - "@metamask/core-backend@npm:^11.0.0, @metamask/core-backend@workspace:packages/core-backend": version: 0.0.0-use.local resolution: "@metamask/core-backend@workspace:packages/core-backend" @@ -7336,7 +7292,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-controller@npm:^28.0.0, @metamask/keyring-controller@npm:^28.1.0, @metamask/keyring-controller@workspace:packages/keyring-controller": +"@metamask/keyring-controller@npm:^28.1.0, @metamask/keyring-controller@workspace:packages/keyring-controller": version: 0.0.0-use.local resolution: "@metamask/keyring-controller@workspace:packages/keyring-controller" dependencies: @@ -7777,7 +7733,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/multichain-account-service@npm:^14.0.0, @metamask/multichain-account-service@npm:^14.1.0, @metamask/multichain-account-service@workspace:packages/multichain-account-service": +"@metamask/multichain-account-service@npm:^14.1.0, @metamask/multichain-account-service@workspace:packages/multichain-account-service": version: 0.0.0-use.local resolution: "@metamask/multichain-account-service@workspace:packages/multichain-account-service" dependencies: @@ -8509,32 +8465,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/profile-sync-controller@npm:^32.0.0": - version: 32.3.1 - resolution: "@metamask/profile-sync-controller@npm:32.3.1" - dependencies: - "@metamask/address-book-controller": "npm:^8.0.0" - "@metamask/base-controller": "npm:^10.0.0" - "@metamask/controller-utils": "npm:^13.0.0" - "@metamask/key-tree": "npm:^10.1.1" - "@metamask/keyring-controller": "npm:^28.1.0" - "@metamask/messenger": "npm:^3.0.0" - "@metamask/seedless-onboarding-controller": "npm:^11.0.0" - "@metamask/superstruct": "npm:^3.4.1" - "@metamask/utils": "npm:^12.0.0" - "@noble/ciphers": "npm:^1.3.0" - "@noble/curves": "npm:^1.9.2" - "@noble/hashes": "npm:^1.8.0" - "@signinwithethereum/siwe": "npm:^4.2.1" - immer: "npm:^9.0.21" - loglevel: "npm:^1.8.1" - peerDependencies: - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/bf591e1d30ec84a5d9e3a1acb88f93b682cf0bf075534c580bb5498fa7ff275dd3af83ff9d010d6aafe88b4eee39c83279c3ce79f552178706b5e80c74b7e46c - languageName: node - linkType: hard - "@metamask/profile-sync-controller@npm:^33.0.0, @metamask/profile-sync-controller@workspace:packages/profile-sync-controller": version: 0.0.0-use.local resolution: "@metamask/profile-sync-controller@workspace:packages/profile-sync-controller" @@ -8764,7 +8694,7 @@ __metadata: languageName: node linkType: hard -"@metamask/seedless-onboarding-controller@npm:^11.0.0, @metamask/seedless-onboarding-controller@npm:^11.0.1, @metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller": +"@metamask/seedless-onboarding-controller@npm:^11.0.1, @metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller": version: 0.0.0-use.local resolution: "@metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller" dependencies: @@ -9127,7 +9057,7 @@ __metadata: "@metamask/base-controller": "npm:^10.0.0" "@metamask/base-data-service": "npm:^2.1.0" "@metamask/controller-utils": "npm:^13.0.0" - "@metamask/core-backend": "npm:^10.0.1" + "@metamask/core-backend": "npm:^11.0.0" "@metamask/messenger": "npm:^3.0.0" "@metamask/profile-sync-controller": "npm:^33.0.0" "@metamask/superstruct": "npm:^3.4.1" @@ -9428,7 +9358,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.10.0, @metamask/utils@npm:^11.11.0, @metamask/utils@npm:^11.12.0, @metamask/utils@npm:^11.4.0, @metamask/utils@npm:^11.4.2, @metamask/utils@npm:^11.8.1, @metamask/utils@npm:^11.9.0": +"@metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.10.0, @metamask/utils@npm:^11.11.0, @metamask/utils@npm:^11.4.0, @metamask/utils@npm:^11.4.2, @metamask/utils@npm:^11.8.1, @metamask/utils@npm:^11.9.0": version: 11.12.1 resolution: "@metamask/utils@npm:11.12.1" dependencies: @@ -11679,7 +11609,7 @@ __metadata: languageName: node linkType: hard -"@tanstack/query-core@npm:5.103.2, @tanstack/query-core@npm:^5.103.2, @tanstack/query-core@npm:^5.62.16": +"@tanstack/query-core@npm:5.103.2, @tanstack/query-core@npm:^5.103.2": version: 5.103.2 resolution: "@tanstack/query-core@npm:5.103.2" checksum: 10/f19ea502e50fd4d0241fc6796d9303637666ce139b751c875eb9f1680278f20bce2dc95d9fbab30f3a997a456e02f9760e6ce9e4e58db71e256591e77bbc65c9 @@ -20632,7 +20562,7 @@ __metadata: languageName: node linkType: hard -"lodash-es@npm:^4.17.21, lodash-es@npm:^4.18.1": +"lodash-es@npm:^4.18.1": version: 4.18.1 resolution: "lodash-es@npm:4.18.1" checksum: 10/8bfad225ef09ef42b04283cdaf7830efcc2ba29ae41b56501c74422155ee1ccaa1f0f6e8319def3451a1fe54dec501c8e4bee622bae2b2d98ac993731e0a5cce From 084e35e7078a92fc5cf9a1f0cfd4f3c5f6e9e568 Mon Sep 17 00:00:00 2001 From: Xavier Brochard Date: Tue, 29 Sep 2026 10:55:43 +0200 Subject: [PATCH 3/4] fix(social-controllers): retry incomplete realtime subscriptions Co-authored-by: Cursor --- .../src/SocialRealtimeService.test.ts | 49 +++++++++++++++++++ .../src/SocialRealtimeService.ts | 7 ++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/social-controllers/src/SocialRealtimeService.test.ts b/packages/social-controllers/src/SocialRealtimeService.test.ts index b752efed0ab..47c964e79c0 100644 --- a/packages/social-controllers/src/SocialRealtimeService.test.ts +++ b/packages/social-controllers/src/SocialRealtimeService.test.ts @@ -348,6 +348,55 @@ describe('SocialRealtimeService', () => { expect(unsubscribe).toHaveBeenCalledTimes(1); }); + it('retries subscription setup when reactivation overlaps subscription cleanup', async () => { + const { call, service, unsubscribe } = createService(); + let resolveSubscription: (value: WebSocketSubscription) => void = () => + undefined; + const subscriptionPromise = new Promise( + (resolve): void => { + resolveSubscription = resolve; + }, + ); + let resolveUnsubscribe: () => void = () => undefined; + const unsubscribePromise = new Promise((resolve): void => { + resolveUnsubscribe = resolve; + }); + unsubscribe.mockReturnValue(unsubscribePromise); + call.mockImplementation((action: string, _options?: unknown): unknown => { + if (action === 'BackendWebSocketService:connect') { + return Promise.resolve(); + } + + if (action === 'BackendWebSocketService:channelHasSubscription') { + return false; + } + + if (action === 'BackendWebSocketService:getSubscriptionsByChannel') { + return []; + } + + return subscriptionPromise; + }); + + const activation = service.setActive(true); + await Promise.resolve(); + await Promise.resolve(); + await service.setActive(false); + resolveSubscription({ unsubscribe } as unknown as WebSocketSubscription); + await Promise.resolve(); + + const reactivation = service.setActive(true); + resolveUnsubscribe(); + await activation; + await reactivation; + + expect( + call.mock.calls.filter( + ([action]) => action === 'BackendWebSocketService:subscribe', + ), + ).toHaveLength(2); + }); + it('swallows subscription errors so feed activation remains best effort', async () => { const { call, service } = createService(); const error = new Error('WebSocket unavailable'); diff --git a/packages/social-controllers/src/SocialRealtimeService.ts b/packages/social-controllers/src/SocialRealtimeService.ts index 9289e9c8060..8b2979f3bcf 100644 --- a/packages/social-controllers/src/SocialRealtimeService.ts +++ b/packages/social-controllers/src/SocialRealtimeService.ts @@ -130,9 +130,12 @@ export class SocialRealtimeService { return; } - if (this.#subscriptionPromise) { + while (this.#subscriptionPromise) { await this.#subscriptionPromise; - return; + + if (!this.#active || this.#subscription) { + return; + } } const subscriptionPromise = Promise.resolve().then( From e388abc05677cf253d7b30f0318b76fd68137e5e Mon Sep 17 00:00:00 2001 From: Xavier Brochard Date: Tue, 29 Sep 2026 11:03:48 +0200 Subject: [PATCH 4/4] fix(social-controllers): restore realtime CI checks Co-authored-by: Cursor --- README.md | 1 + packages/social-controllers/src/SocialRealtimeService.ts | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c25fade294b..ab54de9e2f4 100644 --- a/README.md +++ b/README.md @@ -698,6 +698,7 @@ linkStyle default opacity:0.5 social_controllers --> base_controller; social_controllers --> base_data_service; social_controllers --> controller_utils; + social_controllers --> core_backend; social_controllers --> messenger; social_controllers --> profile_sync_controller; solana_test_validator_up --> local_node_utils; diff --git a/packages/social-controllers/src/SocialRealtimeService.ts b/packages/social-controllers/src/SocialRealtimeService.ts index 8b2979f3bcf..d0771ee2388 100644 --- a/packages/social-controllers/src/SocialRealtimeService.ts +++ b/packages/social-controllers/src/SocialRealtimeService.ts @@ -171,9 +171,7 @@ export class SocialRealtimeService { try { await subscriptionPromise; } finally { - if (this.#subscriptionPromise === subscriptionPromise) { - this.#subscriptionPromise = undefined; - } + this.#subscriptionPromise = undefined; } }