From bc7e1f824ba237abba59b187c7e27e501a66b568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Fri, 18 Sep 2026 14:51:06 +0200 Subject: [PATCH 1/2] Publish device toggled on while joining --- packages/react-client/src/FishjamProvider.tsx | 6 +- .../src/hooks/internal/usePeerStatus.ts | 20 ++- .../src/hooks/internal/useTrackManager.ts | 15 ++- .../src/tests/toggleDuringJoin.spec.ts | 121 ++++++++++++++++++ 4 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 packages/react-client/src/tests/toggleDuringJoin.spec.ts diff --git a/packages/react-client/src/FishjamProvider.tsx b/packages/react-client/src/FishjamProvider.tsx index f5559afe9..d5d22d9e5 100644 --- a/packages/react-client/src/FishjamProvider.tsx +++ b/packages/react-client/src/FishjamProvider.tsx @@ -93,7 +93,7 @@ export function FishjamProvider(props: FishjamProviderProps) { logger, }); - const peerStatus = usePeerStatus(fishjamClientRef.current); + const { peerStatus, getLatestPeerStatus } = usePeerStatus(fishjamClientRef.current); const mergedBandwidthLimits = useMemo( () => mergeWithDefaultBandwitdthLimits(props.bandwidthLimits), @@ -102,7 +102,7 @@ export function FishjamProvider(props: FishjamProviderProps) { const audioTrackManager = useTrackManager({ tsClient: fishjamClientRef.current, - peerStatus, + getLatestPeerStatus, deviceManager: microphoneManager, bandwidthLimits: mergedBandwidthLimits, streamConfig: props.audioConfig, @@ -112,7 +112,7 @@ export function FishjamProvider(props: FishjamProviderProps) { const videoTrackManager = useTrackManager({ tsClient: fishjamClientRef.current, - peerStatus, + getLatestPeerStatus, deviceManager: cameraManager, bandwidthLimits: mergedBandwidthLimits, streamConfig: props.videoConfig, diff --git a/packages/react-client/src/hooks/internal/usePeerStatus.ts b/packages/react-client/src/hooks/internal/usePeerStatus.ts index 5244d430b..9ee95d867 100644 --- a/packages/react-client/src/hooks/internal/usePeerStatus.ts +++ b/packages/react-client/src/hooks/internal/usePeerStatus.ts @@ -1,23 +1,29 @@ import type { FishjamClient } from "@fishjam-cloud/ts-client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { PeerStatus } from "../../types/public"; export const usePeerStatus = (client: FishjamClient) => { const [peerStatus, setPeerStatus] = useState("idle"); + const peerStatusRef = useRef("idle"); + const getLatestPeerStatus = useCallback(() => peerStatusRef.current, []); useEffect(() => { + const updatePeerStatus = (status: PeerStatus) => { + peerStatusRef.current = status; + setPeerStatus(status); + }; const setConnecting = () => { - setPeerStatus("connecting"); + updatePeerStatus("connecting"); }; const setError = () => { - setPeerStatus("error"); + updatePeerStatus("error"); }; const setJoined = () => { - setPeerStatus("connected"); + updatePeerStatus("connected"); }; const setDisconnected = () => { - setPeerStatus("idle"); + updatePeerStatus("idle"); }; client.on("connectionStarted", setConnecting); @@ -37,7 +43,7 @@ export const usePeerStatus = (client: FishjamClient) => { client.off("connectionError", setError); client.off("disconnected", setDisconnected); }; - }, [client, setPeerStatus]); + }, [client]); - return peerStatus; + return { peerStatus, getLatestPeerStatus }; }; diff --git a/packages/react-client/src/hooks/internal/useTrackManager.ts b/packages/react-client/src/hooks/internal/useTrackManager.ts index 13d39b6e2..0b6f6826a 100644 --- a/packages/react-client/src/hooks/internal/useTrackManager.ts +++ b/packages/react-client/src/hooks/internal/useTrackManager.ts @@ -10,7 +10,7 @@ import { useCurrentCallback } from "./useCurrentCallback"; interface TrackManagerConfig { deviceManager: DeviceManager; tsClient: FishjamClient; - peerStatus: PeerStatus; + getPeerStatus: () => PeerStatus; bandwidthLimits: BandwidthLimits; streamConfig?: StreamConfig; type: "camera" | "microphone"; @@ -20,7 +20,7 @@ interface TrackManagerConfig { export const useTrackManager = ({ deviceManager, tsClient, - peerStatus, + getPeerStatus, bandwidthLimits, streamConfig, type, @@ -107,13 +107,13 @@ export const useTrackManager = ({ ); const pauseStreaming = useCurrentCallback(async (trackId: string) => { - if (peerStatus !== "connected") return; + if (getPeerStatus() !== "connected") return; await tsClient.replaceTrack(trackId, null); return tsClient.updateTrackMetadata(trackId, { type, paused: true } satisfies TrackMetadata); }); const resumeStreaming = useCurrentCallback(async (trackId: string, track: MediaStreamTrack) => { - if (peerStatus !== "connected") return; + if (getPeerStatus() !== "connected") return; await tsClient.replaceTrack(trackId, track); return tsClient.updateTrackMetadata(trackId, { type, paused: false } satisfies TrackMetadata); }); @@ -154,9 +154,10 @@ export const useTrackManager = ({ const publishStartedTrack = async (track: MediaStreamTrack | null) => { if (!track) return; - if (currentTrackId) { - await resumeStreaming(currentTrackId, track); - } else if (peerStatus === "connected") { + const publishedTrackId = await getCurrentTrackId(); + if (publishedTrackId) { + await resumeStreaming(publishedTrackId, track); + } else if (getPeerStatus() === "connected") { await startStreaming(track, streamConfig); } }; diff --git a/packages/react-client/src/tests/toggleDuringJoin.spec.ts b/packages/react-client/src/tests/toggleDuringJoin.spec.ts new file mode 100644 index 000000000..782353661 --- /dev/null +++ b/packages/react-client/src/tests/toggleDuringJoin.spec.ts @@ -0,0 +1,121 @@ +import { act } from "@testing-library/react"; + +import { useCamera } from "../hooks/devices/useCamera"; +import { useMicrophone } from "../hooks/devices/useMicrophone"; +import { usePeers } from "../hooks/usePeers"; +import { Deferred } from "../utils/deferred"; +import type { MediaDevicesController } from "./support/fakeMediaDevices"; +import { createFakeStream } from "./support/fakeMediaStream"; +import { describe, expect, it } from "./support/fixtures"; + +const holdNextUserMedia = (media: MediaDevicesController, kind: "audio" | "video") => { + const acquisition = new Deferred(); + media.devices.getUserMedia.mockImplementationOnce(() => acquisition.promise); + const stream = createFakeStream([{ kind, deviceId: `${kind}-1` }]); + return { release: () => acquisition.resolve(stream), track: stream.getTracks()[0] }; +}; + +const publishedKinds = (calls: unknown[][]) => calls.map(([track]) => (track as MediaStreamTrack).kind); + +describe("toggling a device while joining", () => { + it("publishes the microphone when joined fires before the device has started", async ({ + media, + client, + renderHook, + }) => { + const { result } = renderHook(() => ({ mic: useMicrophone(), peers: usePeers() })); + const acquisition = holdNextUserMedia(media, "audio"); + + act(() => client.simulateConnectionStarted()); + let toggle: Promise; + act(() => { + toggle = result.current.mic.toggleMicrophone(); + }); + act(() => client.simulateJoined()); + await act(async () => { + acquisition.release(); + await toggle; + }); + + expect(result.current.mic.isMicrophoneOn).toBe(true); + expect(publishedKinds(client.addTrack.mock.calls)).toEqual(["audio"]); + expect(result.current.peers.localPeer?.microphoneTrack?.track).toBe(acquisition.track); + }); + + it("publishes the camera when joined fires before the device has started", async ({ media, client, renderHook }) => { + const { result } = renderHook(() => ({ cam: useCamera(), peers: usePeers() })); + const acquisition = holdNextUserMedia(media, "video"); + + act(() => client.simulateConnectionStarted()); + let toggle: Promise; + act(() => { + toggle = result.current.cam.toggleCamera(); + }); + act(() => client.simulateJoined()); + await act(async () => { + acquisition.release(); + await toggle; + }); + + expect(result.current.cam.isCameraOn).toBe(true); + expect(publishedKinds(client.addTrack.mock.calls)).toEqual(["video"]); + expect(result.current.peers.localPeer?.cameraTrack?.track).toBe(acquisition.track); + }); + + it("publishes the microphone when toggled in the same tick as joined", async ({ media, client, renderHook }) => { + media.setUserMediaStream(createFakeStream([{ kind: "audio", deviceId: "audio-1" }])); + const { result } = renderHook(() => useMicrophone()); + + act(() => client.simulateConnectionStarted()); + await act(async () => { + client.simulateJoined(); + await result.current.toggleMicrophone(); + }); + + expect(publishedKinds(client.addTrack.mock.calls)).toEqual(["audio"]); + }); + + it("publishes the microphone once when the device starts before joined", async ({ media, client, renderHook }) => { + const { result } = renderHook(() => useMicrophone()); + const acquisition = holdNextUserMedia(media, "audio"); + + act(() => client.simulateConnectionStarted()); + await act(async () => { + const toggle = result.current.toggleMicrophone(); + acquisition.release(); + await toggle; + }); + act(() => client.simulateJoined()); + + expect(publishedKinds(client.addTrack.mock.calls)).toEqual(["audio"]); + }); + + it("does not publish a device toggled on before joining when the join never completes", async ({ + media, + client, + renderHook, + }) => { + media.setUserMediaStream(createFakeStream([{ kind: "audio", deviceId: "audio-1" }])); + const { result } = renderHook(() => useMicrophone()); + + act(() => client.simulateConnectionStarted()); + await act(async () => { + await result.current.toggleMicrophone(); + }); + + expect(result.current.isMicrophoneOn).toBe(true); + expect(client.addTrack).not.toHaveBeenCalled(); + }); + + it("publishes the microphone when toggled after joining", async ({ media, client, renderHook }) => { + media.setUserMediaStream(createFakeStream([{ kind: "audio", deviceId: "audio-1" }])); + const { result } = renderHook(() => useMicrophone()); + + act(() => client.simulateJoined()); + await act(async () => { + await result.current.toggleMicrophone(); + }); + + expect(publishedKinds(client.addTrack.mock.calls)).toEqual(["audio"]); + }); +}); From 44b227c2dad1a15ced4ac45f8a6bce1b110708ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Fri, 18 Sep 2026 14:59:55 +0200 Subject: [PATCH 2/2] Rename peer status getter in track manager --- .../react-client/src/hooks/internal/useTrackManager.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/react-client/src/hooks/internal/useTrackManager.ts b/packages/react-client/src/hooks/internal/useTrackManager.ts index 0b6f6826a..cf0c592c1 100644 --- a/packages/react-client/src/hooks/internal/useTrackManager.ts +++ b/packages/react-client/src/hooks/internal/useTrackManager.ts @@ -10,7 +10,7 @@ import { useCurrentCallback } from "./useCurrentCallback"; interface TrackManagerConfig { deviceManager: DeviceManager; tsClient: FishjamClient; - getPeerStatus: () => PeerStatus; + getLatestPeerStatus: () => PeerStatus; bandwidthLimits: BandwidthLimits; streamConfig?: StreamConfig; type: "camera" | "microphone"; @@ -20,7 +20,7 @@ interface TrackManagerConfig { export const useTrackManager = ({ deviceManager, tsClient, - getPeerStatus, + getLatestPeerStatus, bandwidthLimits, streamConfig, type, @@ -107,13 +107,13 @@ export const useTrackManager = ({ ); const pauseStreaming = useCurrentCallback(async (trackId: string) => { - if (getPeerStatus() !== "connected") return; + if (getLatestPeerStatus() !== "connected") return; await tsClient.replaceTrack(trackId, null); return tsClient.updateTrackMetadata(trackId, { type, paused: true } satisfies TrackMetadata); }); const resumeStreaming = useCurrentCallback(async (trackId: string, track: MediaStreamTrack) => { - if (getPeerStatus() !== "connected") return; + if (getLatestPeerStatus() !== "connected") return; await tsClient.replaceTrack(trackId, track); return tsClient.updateTrackMetadata(trackId, { type, paused: false } satisfies TrackMetadata); }); @@ -157,7 +157,7 @@ export const useTrackManager = ({ const publishedTrackId = await getCurrentTrackId(); if (publishedTrackId) { await resumeStreaming(publishedTrackId, track); - } else if (getPeerStatus() === "connected") { + } else if (getLatestPeerStatus() === "connected") { await startStreaming(track, streamConfig); } };