From 5511ddac8ca3d61f718f93654fbfc23faef9b3ce Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 19 Nov 2025 18:16:16 +0100 Subject: [PATCH 1/4] fix: getSnapshot caching --- .../src/hooks/useObservableValue.ts | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/react-bindings/src/hooks/useObservableValue.ts b/packages/react-bindings/src/hooks/useObservableValue.ts index bd735236f2..a10be9aa0e 100644 --- a/packages/react-bindings/src/hooks/useObservableValue.ts +++ b/packages/react-bindings/src/hooks/useObservableValue.ts @@ -1,5 +1,5 @@ import type { Observable } from 'rxjs'; -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { RxUtils } from '@stream-io/video-client'; @@ -12,32 +12,32 @@ import { RxUtils } from '@stream-io/video-client'; export const useObservableValue = ( observable$: Observable, defaultValue?: T, -) => { - const getSnapshot = useCallback(() => { +): T => { + const initialRenderRef = useRef(false); + const valueRef = useRef(undefined); + if (valueRef.current === undefined && !initialRenderRef.current) { + initialRenderRef.current = true; try { - return RxUtils.getCurrentValue(observable$); + valueRef.current = RxUtils.getCurrentValue(observable$); } catch (error) { if (typeof defaultValue === 'undefined') throw error; - return defaultValue; + valueRef.current = defaultValue; } - }, [defaultValue, observable$]); + } const subscribe = useCallback( - (onStoreChange: (v: T) => void) => { - const unsubscribe = RxUtils.createSubscription( + (onStoreChange: () => void) => + RxUtils.createSubscription( observable$, - onStoreChange, - (error) => { - console.log('An error occurred while reading an observable', error); - - if (defaultValue) onStoreChange(defaultValue); + (value: T) => { + valueRef.current = value; + onStoreChange(); }, - ); - - return unsubscribe; - }, - [defaultValue, observable$], + (err) => console.log('Failed to read an observable', err), + ), + [observable$], ); + const getSnapshot = useCallback(() => valueRef.current as T, []); return useSyncExternalStore(subscribe, getSnapshot); }; From ad0b5c56036410e628f90209308e19b12a9f1276 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Thu, 20 Nov 2025 12:41:12 +0100 Subject: [PATCH 2/4] Stabilize remoteParticipants$ output --- packages/client/src/store/CallState.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/client/src/store/CallState.ts b/packages/client/src/store/CallState.ts index afecdb169d..dabf21364b 100644 --- a/packages/client/src/store/CallState.ts +++ b/packages/client/src/store/CallState.ts @@ -74,6 +74,8 @@ type OrphanedTrack = { track: MediaStream; }; +const stableEmptyArray: StreamVideoParticipant[] = []; + /** * Holds the state of the current call. * @react You don't have to use this class directly, as we are exposing the state through Hooks. @@ -353,7 +355,23 @@ export class CallState { ); this.remoteParticipants$ = this.participants$.pipe( - map((participants) => participants.filter((p) => !p.isLocalParticipant)), + map((participants) => { + // no need to filter if there are no participants + if (!participants.length) { + return participants; + } + + const filteredParticipants = participants.filter( + (p) => !p.isLocalParticipant, + ); + + // return a stable empty array if there are no remote participants + if (!filteredParticipants.length) { + return stableEmptyArray; + } + + return filteredParticipants; + }), shareReplay({ bufferSize: 1, refCount: true }), ); From 5c1053d6b39d586d0ff8d4ca6e0f93b4c4148d05 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 20 Nov 2025 13:39:36 +0100 Subject: [PATCH 3/4] Revert "fix: getSnapshot caching" This reverts commit 5511ddac8ca3d61f718f93654fbfc23faef9b3ce. --- .../src/hooks/useObservableValue.ts | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/react-bindings/src/hooks/useObservableValue.ts b/packages/react-bindings/src/hooks/useObservableValue.ts index a10be9aa0e..bd735236f2 100644 --- a/packages/react-bindings/src/hooks/useObservableValue.ts +++ b/packages/react-bindings/src/hooks/useObservableValue.ts @@ -1,5 +1,5 @@ import type { Observable } from 'rxjs'; -import { useCallback, useRef } from 'react'; +import { useCallback } from 'react'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { RxUtils } from '@stream-io/video-client'; @@ -12,32 +12,32 @@ import { RxUtils } from '@stream-io/video-client'; export const useObservableValue = ( observable$: Observable, defaultValue?: T, -): T => { - const initialRenderRef = useRef(false); - const valueRef = useRef(undefined); - if (valueRef.current === undefined && !initialRenderRef.current) { - initialRenderRef.current = true; +) => { + const getSnapshot = useCallback(() => { try { - valueRef.current = RxUtils.getCurrentValue(observable$); + return RxUtils.getCurrentValue(observable$); } catch (error) { if (typeof defaultValue === 'undefined') throw error; - valueRef.current = defaultValue; + return defaultValue; } - } + }, [defaultValue, observable$]); const subscribe = useCallback( - (onStoreChange: () => void) => - RxUtils.createSubscription( + (onStoreChange: (v: T) => void) => { + const unsubscribe = RxUtils.createSubscription( observable$, - (value: T) => { - valueRef.current = value; - onStoreChange(); + onStoreChange, + (error) => { + console.log('An error occurred while reading an observable', error); + + if (defaultValue) onStoreChange(defaultValue); }, - (err) => console.log('Failed to read an observable', err), - ), - [observable$], + ); + + return unsubscribe; + }, + [defaultValue, observable$], ); - const getSnapshot = useCallback(() => valueRef.current as T, []); return useSyncExternalStore(subscribe, getSnapshot); }; From 43cc7344530c7651a76c4efc5a0c2127246e1f14 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 20 Nov 2025 14:12:00 +0100 Subject: [PATCH 4/4] chore: extract filtering utility, introduce the missing usePinnedParticipants hook --- packages/client/src/store/CallState.ts | 43 +++++++++++-------- .../src/hooks/callStateHooks.ts | 8 ++++ 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/packages/client/src/store/CallState.ts b/packages/client/src/store/CallState.ts index dabf21364b..4048bb3a39 100644 --- a/packages/client/src/store/CallState.ts +++ b/packages/client/src/store/CallState.ts @@ -74,7 +74,28 @@ type OrphanedTrack = { track: MediaStream; }; -const stableEmptyArray: StreamVideoParticipant[] = []; +/** + * Creates a stable participant filter function, ready to be used in combination + * with the `useSyncExternalStore` hook. + * + * @param predicate the predicate to use. + */ +const createStableParticipantsFilter = ( + predicate: (p: StreamVideoParticipant) => boolean, +) => { + const empty: StreamVideoParticipant[] = []; + return (participants: StreamVideoParticipant[]) => { + // no need to filter if there are no participants + if (!participants.length) return participants; + + // return a stable empty array if there are no remote participants + // instead of creating an empty one + const filteredParticipants = participants.filter(predicate); + if (!filteredParticipants.length) return empty; + + return filteredParticipants; + }; +}; /** * Holds the state of the current call. @@ -355,28 +376,12 @@ export class CallState { ); this.remoteParticipants$ = this.participants$.pipe( - map((participants) => { - // no need to filter if there are no participants - if (!participants.length) { - return participants; - } - - const filteredParticipants = participants.filter( - (p) => !p.isLocalParticipant, - ); - - // return a stable empty array if there are no remote participants - if (!filteredParticipants.length) { - return stableEmptyArray; - } - - return filteredParticipants; - }), + map(createStableParticipantsFilter((p) => !p.isLocalParticipant)), shareReplay({ bufferSize: 1, refCount: true }), ); this.pinnedParticipants$ = this.participants$.pipe( - map((participants) => participants.filter((p) => !!p.pin)), + map(createStableParticipantsFilter((p) => !!p.pin)), shareReplay({ bufferSize: 1, refCount: true }), ); diff --git a/packages/react-bindings/src/hooks/callStateHooks.ts b/packages/react-bindings/src/hooks/callStateHooks.ts index df7fc5a9a1..6a39732963 100644 --- a/packages/react-bindings/src/hooks/callStateHooks.ts +++ b/packages/react-bindings/src/hooks/callStateHooks.ts @@ -293,6 +293,14 @@ export const useRemoteParticipants = () => { return useObservableValue(remoteParticipants$); }; +/** + * A hook which provides a list of participants that are currently pinned. + */ +export const usePinnedParticipants = () => { + const { pinnedParticipants$ } = useCallState(); + return useObservableValue(pinnedParticipants$); +}; + /** * Returns the approximate participant count of the active call. * This includes the anonymous users as well, and it is computed on the server.