From bcc319c4ddfaa8e9bf583d9adba60ca5c549a1af Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 4 Aug 2026 18:04:11 -0300 Subject: [PATCH 1/3] refactor(ui-voip): own the media session lifecycle from an effect The session was exposed through useSyncExternalStore, which meant the store had to be fed by three separate effects (send signal fn, webrtc processor factory, instance creation) before it could hand out an instance, and any ICE setting change replaced the factory. Now the hook creates the session in an effect and keeps it in state: - iceServers/iceGatheringTimeout are read through a stable getter on every processor creation, so setting changes reach new calls without recreating the session; - getInstance is idempotent per userId, so a re-render (StrictMode, discarded render) no longer ends a live session; - getOldSessionId ignores an id this tab created itself, so a recreated instance doesn't try to resume the session it just replaced; - the store no longer emits 'change': with the instance in React state there were no subscribers left. --- .../media-session-instance-lifecycle.md | 6 + .../src/providers/useMediaSessionInstance.ts | 162 +++++++----------- 2 files changed, 69 insertions(+), 99 deletions(-) create mode 100644 .changeset/media-session-instance-lifecycle.md diff --git a/.changeset/media-session-instance-lifecycle.md b/.changeset/media-session-instance-lifecycle.md new file mode 100644 index 0000000000000..607a5164828c2 --- /dev/null +++ b/.changeset/media-session-instance-lifecycle.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/ui-voip': patch +'@rocket.chat/meteor': patch +--- + +Fixes the media call session being ended and recreated on re-renders, which could drop an ongoing call, and makes ICE server and ICE gathering timeout changes apply to new calls without recreating the session diff --git a/packages/ui-voip/src/providers/useMediaSessionInstance.ts b/packages/ui-voip/src/providers/useMediaSessionInstance.ts index abf4b13cc3c23..87f3d85711621 100644 --- a/packages/ui-voip/src/providers/useMediaSessionInstance.ts +++ b/packages/ui-voip/src/providers/useMediaSessionInstance.ts @@ -1,9 +1,10 @@ import { Emitter } from '@rocket.chat/emitter'; +import { useStableCallback } from '@rocket.chat/fuselage-hooks'; import { MediaSignalingSession, MediaCallWebRTCProcessor } from '@rocket.chat/media-signaling'; -import type { MediaSignalTransport, ClientMediaSignal, ServerMediaSignal, WebRTCProcessorConfig } from '@rocket.chat/media-signaling'; +import type { MediaSignalTransport, ClientMediaSignal, ServerMediaSignal } from '@rocket.chat/media-signaling'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useSetting, useStream, useToastMessageDispatch, useWriteStream } from '@rocket.chat/ui-contexts'; -import { useEffect, useSyncExternalStore, useCallback } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { MediaCallLogger } from './MediaCallLogger'; @@ -25,7 +26,6 @@ const getSessionIdKey = (userId: string) => { }; type MediaSessionStoreEventMap = { - change: void; requestToast: { message: TranslationKey; args?: Record; type: 'error' | 'success' | 'info' | 'warning' }; }; @@ -70,37 +70,22 @@ class MediaSessionStore extends Emitter { private sendSignalFn: SignalTransport | null = null; - private _webrtcProcessorFactory: ((config: WebRTCProcessorConfig) => MediaCallWebRTCProcessor) | null = null; - private failedScreenShareAttempts = 0; private logger = new MediaCallLogger(); private popoutWindow: Window | undefined; + private lastSessionId: string | undefined; + constructor() { super(); } - private change() { - this.emit('change'); - } - - public onChange(callback: () => void) { - return this.on('change', callback); - } - private requestToast({ message, args, type }: MediaSessionStoreEventMap['requestToast']) { this.emit('requestToast', { message, args, type }); } - private webrtcProcessorFactory(config: WebRTCProcessorConfig) { - if (!this._webrtcProcessorFactory) { - throw new Error('WebRTC processor factory not set'); - } - return this._webrtcProcessorFactory(config); - } - private sendSignal(signal: ClientMediaSignal) { if (this.sendSignalFn) { return this.sendSignalFn(signal); @@ -124,6 +109,13 @@ class MediaSessionStore extends Emitter { } window.sessionStorage.removeItem(key); + + // never resume a session this tab created itself: the stored id is written on creation, so a re-created + // instance (StrictMode remount, userId change and back) would otherwise adopt the id of the one it replaced + if (oldSessionId === this.lastSessionId) { + return undefined; + } + return oldSessionId; } @@ -182,21 +174,38 @@ class MediaSessionStore extends Emitter { } } - private cleanupInstance() { - if (this.sessionInstance !== null) { - this.sessionInstance.endSession(); - this.sessionInstance = null; + public cleanupInstance() { + if (this.sessionInstance === null) { + return; } + this.sessionInstance.endSession(); + this.sessionInstance = null; + this.sendSignalFn = null; } - private makeInstance(userId: string) { + public getInstance( + userId: string, + sendSignalFn: SignalTransport, + getWebRTCConfig: () => { iceServers: RTCIceServer[]; iceGatheringTimeout: number }, + ) { + // must be idempotent: it's called from render, which may run more than once per commit (StrictMode, discarded renders) + if (this.sessionInstance?.userId === userId) { + return this.sessionInstance; + } + return this.makeInstance(userId, sendSignalFn, getWebRTCConfig); + } + + private makeInstance( + userId: string, + sendSignalFn: SignalTransport, + getWebRTCConfig: () => { iceServers: RTCIceServer[]; iceGatheringTimeout: number }, + ) { this.cleanupInstance(); this.failedScreenShareAttempts = 0; - if (!this._webrtcProcessorFactory || !this.sendSignalFn) { - return null; - } + // must be set before the session is constructed: the constructor already sends the register signal + this.sendSignalFn = sendSignalFn; this.sessionInstance = new MediaSignalingSession({ userId, @@ -204,7 +213,11 @@ class MediaSessionStore extends Emitter { void this.sendSignal(signal); }, processorFactories: { - webrtc: (config) => this.webrtcProcessorFactory(config), + // config read on every processor creation, so setting/ice changes apply without recreating the session + webrtc: (config) => { + const { iceServers, iceGatheringTimeout } = getWebRTCConfig(); + return new MediaCallWebRTCProcessor({ ...config, rtc: { ...config.rtc, iceServers }, iceGatheringTimeout }); + }, }, displayMediaFactory: (...args) => this.getDisplayMedia(...args), mediaStreamFactory: (...args) => this.getUserMedia(...args), @@ -215,53 +228,15 @@ class MediaSessionStore extends Emitter { autoSync: true, }); + this.lastSessionId = this.sessionInstance.sessionId; + if (window.sessionStorage) { window.sessionStorage.setItem(getSessionIdKey(userId), this.sessionInstance.sessionId); } - this.change(); - return this.sessionInstance; } - public getInstance(userId?: string, enabled = true) { - if (!enabled) { - this.cleanupInstance(); - return null; - } - - if (!userId) { - return null; - } - - if (this.sessionInstance?.userId === userId) { - return this.sessionInstance; - } - - return this.makeInstance(userId); - } - - public setSendSignalFn(sendSignalFn: SignalTransport) { - this.sendSignalFn = sendSignalFn; - this.change(); - return () => { - this.sendSignalFn = null; - }; - } - - public setWebRTCProcessorFactory(factory: (config: WebRTCProcessorConfig) => MediaCallWebRTCProcessor) { - this._webrtcProcessorFactory = factory; - this.change(); - } - - public processSignal(signal: ServerMediaSignal, userId?: string) { - if (!this.sessionInstance || this.sessionInstance.userId !== userId) { - return; - } - - void this.sessionInstance.processSignal(signal); - } - public setPopoutWindow(popoutWindow?: Window) { if (!popoutWindow) { this.popoutWindow = undefined; @@ -280,6 +255,7 @@ export const useSetPopoutWindow = (popoutWindow?: Window) => { }; export const useMediaSessionInstance = (userId?: string, enabled = true) => { + const [instance, setInstance] = useState(undefined); const { t } = useTranslation(); const iceServers = useIceServers(); const iceGatheringTimeout = useSetting('VoIP_TeamCollab_Ice_Gathering_Timeout', 5000); @@ -289,45 +265,33 @@ export const useMediaSessionInstance = (userId?: string, enabled = true) => { const dispatchToastMessage = useToastMessageDispatch(); - useEffect(() => { - mediaSession.setWebRTCProcessorFactory( - (config) => new MediaCallWebRTCProcessor({ ...config, rtc: { ...config.rtc, iceServers }, iceGatheringTimeout }), - ); - }, [iceServers, iceGatheringTimeout]); + useEffect( + () => mediaSession.on('requestToast', ({ message, args, type }) => dispatchToastMessage({ message: t(message, args), type })), + [dispatchToastMessage, t], + ); - useEffect(() => { - // TODO: This stream is not typed. - return mediaSession.setSendSignalFn((signal: ClientMediaSignal) => writeStream(`${userId}/media-calls` as any, JSON.stringify(signal))); - }, [writeStream, userId]); + const sendSignal = useStableCallback((signal: ClientMediaSignal) => writeStream(`${userId}/media-calls` as any, JSON.stringify(signal))); + const getWebRTCConfig = useStableCallback(() => ({ iceServers, iceGatheringTimeout })); useEffect(() => { - if (!userId) { + if (!userId || !enabled) { + setInstance(undefined); return; } - const unsubNotification = notifyUserStream(`${userId}/media-signal`, (signal: ServerMediaSignal) => - mediaSession.processSignal(signal, userId), - ); + const instance = mediaSession.getInstance(userId, sendSignal, getWebRTCConfig); + + setInstance(instance); + + const subscription = notifyUserStream(`${userId}/media-signal`, (signal: ServerMediaSignal) => instance.processSignal(signal)); return () => { - unsubNotification(); + subscription(); + mediaSession.cleanupInstance(); }; - }, [userId, notifyUserStream]); + }, [userId, enabled, sendSignal, getWebRTCConfig, notifyUserStream]); + - useEffect(() => { - return mediaSession.on('requestToast', ({ message, args, type }) => { - dispatchToastMessage({ message: t(message, args), type }); - }); - }, [dispatchToastMessage, t]); - - const instance = useSyncExternalStore( - useCallback((callback) => { - return mediaSession.onChange(callback); - }, []), - useCallback(() => { - return mediaSession.getInstance(userId, enabled); - }, [userId, enabled]), - ); - return instance ?? undefined; + return instance; }; From afb0323002c46ca35aaf4ee08f02f6778dfbdbbe Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 4 Aug 2026 18:05:34 -0300 Subject: [PATCH 2/3] Delete .changeset/media-session-instance-lifecycle.md --- .changeset/media-session-instance-lifecycle.md | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 .changeset/media-session-instance-lifecycle.md diff --git a/.changeset/media-session-instance-lifecycle.md b/.changeset/media-session-instance-lifecycle.md deleted file mode 100644 index 607a5164828c2..0000000000000 --- a/.changeset/media-session-instance-lifecycle.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@rocket.chat/ui-voip': patch -'@rocket.chat/meteor': patch ---- - -Fixes the media call session being ended and recreated on re-renders, which could drop an ongoing call, and makes ICE server and ICE gathering timeout changes apply to new calls without recreating the session From ba7aec462a749a5ce88ff2711e167f269c89250a Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 4 Aug 2026 18:16:13 -0300 Subject: [PATCH 3/3] chore: fix formatting --- packages/ui-voip/src/providers/useMediaSessionInstance.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/ui-voip/src/providers/useMediaSessionInstance.ts b/packages/ui-voip/src/providers/useMediaSessionInstance.ts index 87f3d85711621..33d49f2dfe376 100644 --- a/packages/ui-voip/src/providers/useMediaSessionInstance.ts +++ b/packages/ui-voip/src/providers/useMediaSessionInstance.ts @@ -291,7 +291,5 @@ export const useMediaSessionInstance = (userId?: string, enabled = true) => { }; }, [userId, enabled, sendSignal, getWebRTCConfig, notifyUserStream]); - - return instance; };