Skip to content

Commit bb70155

Browse files
authored
FCE-3288 use current callback references (#526)
## Description Introduces `useCurrentCallback` hook that ensures the closure values are always latest. ## Motivation and Context Invoking methods one after another in `useEffect` was prone to stale closure issue. ## Documentation impact - [ ] Documentation update required - [ ] Documentation updated [in another PR](_) - [x] No documentation update required ## Types of changes - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
1 parent 798efc1 commit bb70155

5 files changed

Lines changed: 150 additions & 79 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useCallback, useLayoutEffect, useRef } from "react";
2+
3+
/**
4+
* Returns a stable function reference whose body always reads the latest
5+
* closure values. Use when a method needs to be called from effects or
6+
* captured by consumers without being memoized against changing state
7+
* (peerStatus, deviceTrack, etc.) in its dep array.
8+
*/
9+
export const useCurrentCallback = <Args extends unknown[], R>(handler: (...args: Args) => R) => {
10+
const ref = useRef(handler);
11+
useLayoutEffect(() => {
12+
ref.current = handler;
13+
});
14+
return useCallback((...args: Args) => ref.current(...args), []);
15+
};

packages/react-client/src/hooks/internal/useScreenshareManager.ts

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
33

44
import type { ScreenShareState } from "../../types/internal";
55
import type { PeerStatus, TracksMiddleware } from "../../types/public";
6+
import { useCurrentCallback } from "./useCurrentCallback";
67

78
export type UseScreenshareResult = {
89
/**
@@ -160,7 +161,9 @@ export const useScreenShareManager = ({
160161
[state.stream, cleanMiddleware, replaceTracks],
161162
);
162163

163-
const stopStreaming: UseScreenshareResult["stopStreaming"] = useCallback(async () => {
164+
// Stable identity with a live closure: peerStatus must be observed at call
165+
// time so a captured reference doesn't skip the SFU removeTrack calls.
166+
const stopStreaming: UseScreenshareResult["stopStreaming"] = useCurrentCallback(async () => {
164167
if (!state.stream) {
165168
logger.warn("No stream to stop");
166169
return;
@@ -180,22 +183,16 @@ export const useScreenShareManager = ({
180183

181184
cleanMiddleware();
182185
setState((prev) => ({ stream: null, trackIds: null, tracksMiddleware: prev.tracksMiddleware }));
183-
}, [
184-
state.stream,
185-
state.trackIds?.videoId,
186-
state.trackIds?.audioId,
187-
peerStatus,
188-
cleanMiddleware,
189-
logger,
190-
fishjamClient,
191-
]);
186+
});
192187

193188
useEffect(() => {
194189
if (!state.stream) return;
195190
const [video, audio] = getTracksFromStream(state.stream);
196191

197192
const trackEndedHandler = () => {
198-
stopStreaming();
193+
void stopStreaming().catch((err) => {
194+
logger.error(err);
195+
});
199196
};
200197

201198
video.addEventListener("ended", trackEndedHandler);
@@ -205,20 +202,21 @@ export const useScreenShareManager = ({
205202
video.removeEventListener("ended", trackEndedHandler);
206203
audio?.removeEventListener("ended", trackEndedHandler);
207204
};
208-
}, [state, stopStreaming]);
205+
}, [state, stopStreaming, logger]);
209206

210207
useEffect(() => {
211208
const onDisconnected = () => {
212-
if (stream) {
213-
stopStreaming();
214-
}
209+
if (!stream) return;
210+
void stopStreaming().catch((err) => {
211+
logger.error(err);
212+
});
215213
};
216214
fishjamClient.on("disconnected", onDisconnected);
217215

218216
return () => {
219217
fishjamClient.removeListener("disconnected", onDisconnected);
220218
};
221-
}, [stopStreaming, fishjamClient, stream]);
219+
}, [stopStreaming, fishjamClient, stream, logger]);
222220

223221
return {
224222
startStreaming,

packages/react-client/src/hooks/internal/useTrackManager.ts

Lines changed: 48 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { type FishjamClient, type Logger, type TrackMetadata, TrackTypeError, Variant } from "@fishjam-cloud/ts-client";
2-
import { useCallback, useEffect, useRef } from "react";
2+
import { useEffect, useRef } from "react";
33

44
import type { TrackManager } from "../../types/internal";
55
import type { BandwidthLimits, PeerStatus, StreamConfig, TrackMiddleware } from "../../types/public";
66
import { getConfigAndBandwidthFromProps, getRemoteOrLocalTrack } from "../../utils/track";
77
import type { DeviceManager } from "./devices/useDeviceManager";
8+
import { useCurrentCallback } from "./useCurrentCallback";
89

910
interface TrackManagerConfig {
1011
deviceManager: DeviceManager;
@@ -38,42 +39,40 @@ export const useTrackManager = ({
3839
selectDevice: _selectDevice,
3940
} = deviceManager;
4041

41-
const getCurrentTrackId = useCallback(() => {
42+
// Read live deviceTrack from the `joined` listener without re-subscribing
43+
// every time it changes.
44+
const getDeviceTrack = useCurrentCallback(() => deviceTrack);
45+
46+
const getCurrentTrackId = (): string | null => {
4247
const refTrackId = currentTrackIdRef.current;
4348
if (!refTrackId) return null;
4449
const currentTrack = getRemoteOrLocalTrack(tsClient, refTrackId);
4550
return currentTrack?.trackId ?? null;
46-
}, [tsClient]);
51+
};
4752

48-
const selectDevice = useCallback(
49-
async (deviceId: string) => {
50-
const result = await _selectDevice(deviceId);
51-
if (!result) return;
53+
const selectDevice = useCurrentCallback(async (deviceId: string) => {
54+
const result = await _selectDevice(deviceId);
55+
if (!result) return;
5256

53-
const [newTrack, error] = result;
54-
if (error) return error;
57+
const [newTrack, error] = result;
58+
if (error) return error;
5559

56-
const currentTrackId = getCurrentTrackId();
57-
if (!currentTrackId) return;
60+
const currentTrackId = getCurrentTrackId();
61+
if (!currentTrackId) return;
5862

59-
await tsClient.replaceTrack(currentTrackId, newTrack);
60-
},
61-
[getCurrentTrackId, _selectDevice, tsClient],
62-
);
63+
await tsClient.replaceTrack(currentTrackId, newTrack);
64+
});
6365

64-
const setTrackMiddleware = useCallback(
65-
async (middleware: TrackMiddleware) => {
66-
const processedTrack = await applyMiddleware(middleware);
66+
const setTrackMiddleware = useCurrentCallback(async (middleware: TrackMiddleware) => {
67+
const processedTrack = await applyMiddleware(middleware);
6768

68-
const currentTrackId = getCurrentTrackId();
69-
if (!currentTrackId) return;
69+
const currentTrackId = getCurrentTrackId();
70+
if (!currentTrackId) return;
7071

71-
await tsClient.replaceTrack(currentTrackId, processedTrack);
72-
},
73-
[applyMiddleware, getCurrentTrackId, tsClient],
74-
);
72+
await tsClient.replaceTrack(currentTrackId, processedTrack);
73+
});
7574

76-
const startStreaming = useCallback(
75+
const startStreaming = useCurrentCallback(
7776
async (
7877
track: MediaStreamTrack,
7978
props: StreamConfig = { sentQualities: [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH] },
@@ -102,31 +101,24 @@ export const useTrackManager = ({
102101
throw err;
103102
}
104103
},
105-
[type, tsClient, bandwidthLimits, logger],
106104
);
107105

108-
const pauseStreaming = useCallback(
109-
async (trackId: string) => {
110-
if (peerStatus !== "connected") return;
111-
await tsClient.replaceTrack(trackId, null);
112-
return tsClient.updateTrackMetadata(trackId, { type, paused: true } satisfies TrackMetadata);
113-
},
114-
[tsClient, type, peerStatus],
115-
);
106+
const pauseStreaming = useCurrentCallback(async (trackId: string) => {
107+
if (peerStatus !== "connected") return;
108+
await tsClient.replaceTrack(trackId, null);
109+
return tsClient.updateTrackMetadata(trackId, { type, paused: true } satisfies TrackMetadata);
110+
});
116111

117-
const resumeStreaming = useCallback(
118-
async (trackId: string, track: MediaStreamTrack) => {
119-
if (peerStatus !== "connected") return;
120-
await tsClient.replaceTrack(trackId, track);
121-
return tsClient.updateTrackMetadata(trackId, { type, paused: false } satisfies TrackMetadata);
122-
},
123-
[peerStatus, tsClient, type],
124-
);
112+
const resumeStreaming = useCurrentCallback(async (trackId: string, track: MediaStreamTrack) => {
113+
if (peerStatus !== "connected") return;
114+
await tsClient.replaceTrack(trackId, track);
115+
return tsClient.updateTrackMetadata(trackId, { type, paused: false } satisfies TrackMetadata);
116+
});
125117

126118
/**
127119
* @see {@link TrackManager#toggleMute} for more details.
128120
*/
129-
const toggleMute = useCallback(async () => {
121+
const toggleMute = useCurrentCallback(async () => {
130122
const currentTrackId = getCurrentTrackId();
131123
const isTrackCurrentlyEnabled = Boolean(deviceTrack?.enabled);
132124
if (!currentTrackId) {
@@ -141,17 +133,17 @@ export const useTrackManager = ({
141133
enableDevice();
142134
await resumeStreaming(currentTrackId, deviceTrack);
143135
}
144-
}, [deviceTrack, getCurrentTrackId, logger, disableDevice, pauseStreaming, enableDevice, resumeStreaming]);
136+
});
145137

146138
/**
147139
* @see {@link TrackManager#toggleDevice} for more details.
148140
*/
149-
const toggleDevice = useCallback(async () => {
141+
const toggleDevice = useCurrentCallback(async () => {
150142
const currentTrackId = getCurrentTrackId();
151143
if (deviceTrack) {
152144
stopDevice();
153145
if (currentTrackId) {
154-
pauseStreaming(currentTrackId);
146+
await pauseStreaming(currentTrackId);
155147
}
156148
} else {
157149
const [newTrack, error] = await startDevice();
@@ -163,23 +155,18 @@ export const useTrackManager = ({
163155
await startStreaming(newTrack, streamConfig);
164156
}
165157
}
166-
}, [
167-
getCurrentTrackId,
168-
deviceTrack,
169-
stopDevice,
170-
pauseStreaming,
171-
startDevice,
172-
peerStatus,
173-
resumeStreaming,
174-
startStreaming,
175-
streamConfig,
176-
]);
158+
});
177159

178160
useEffect(() => {
179161
const onJoinedRoom = () => {
180-
if (deviceTrack) {
181-
startStreaming(deviceTrack, streamConfig);
182-
}
162+
const currentDeviceTrack = getDeviceTrack();
163+
if (!currentDeviceTrack) return;
164+
// The handler is sync; observe rejections so non-TrackTypeError failures
165+
// from addTrack don't surface as unhandledrejection.
166+
void startStreaming(currentDeviceTrack, streamConfig).catch((err) => {
167+
if (err instanceof TrackTypeError) return;
168+
logger.error(err);
169+
});
183170
};
184171

185172
const onLeftRoom = () => {
@@ -192,7 +179,7 @@ export const useTrackManager = ({
192179
tsClient.off("joined", onJoinedRoom);
193180
tsClient.off("disconnected", onLeftRoom);
194181
};
195-
}, [deviceTrack, startStreaming, tsClient, streamConfig]);
182+
}, [startStreaming, tsClient, streamConfig, getDeviceTrack, logger]);
196183

197184
return {
198185
deviceTrack,

packages/react-client/src/hooks/useDataChannel.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useCallback, useContext, useEffect, useState } from "react";
44
import { FishjamClientContext } from "../contexts/fishjamClient";
55
import { PeerStatusContext } from "../contexts/peerStatus";
66
import type { UseDataChannelResult } from "../types/public";
7+
import { useCurrentCallback } from "./internal/useCurrentCallback";
78

89
/**
910
* Hook for data channel operations - publish and subscribe to data.
@@ -49,7 +50,10 @@ export function useDataChannel(): UseDataChannelResult {
4950
};
5051
}, [client]);
5152

52-
const initialize = useCallback(async () => {
53+
// Stable identity with a live closure: peerStatus / loading / ready must be
54+
// observed at call time so a captured reference doesn't reject with a stale
55+
// "Peer is not connected" right after the connect promise settles.
56+
const initialize = useCurrentCallback(async () => {
5357
if (loading || ready) return;
5458

5559
if (peerStatus !== "connected") {
@@ -67,7 +71,7 @@ export function useDataChannel(): UseDataChannelResult {
6771
} finally {
6872
setLoading(false);
6973
}
70-
}, [client, peerStatus, loading, ready]);
74+
});
7175

7276
const publishData = useCallback(
7377
(data: Uint8Array, options: DataChannelOptions) => {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { renderHook } from "@testing-library/react";
2+
import { describe, expect, it, vi } from "vitest";
3+
4+
import { useCurrentCallback } from "../hooks/internal/useCurrentCallback";
5+
6+
describe("useCurrentCallback", () => {
7+
it("returns a stable function reference across re-renders", () => {
8+
const { result, rerender } = renderHook((handler: () => void) => useCurrentCallback(handler), {
9+
initialProps: () => undefined,
10+
});
11+
12+
const firstRef = result.current;
13+
rerender(() => undefined);
14+
rerender(() => undefined);
15+
16+
expect(result.current).toBe(firstRef);
17+
});
18+
19+
it("invokes the latest handler when called after a re-render", () => {
20+
const first = vi.fn();
21+
const second = vi.fn();
22+
23+
const { result, rerender } = renderHook((handler: () => void) => useCurrentCallback(handler), {
24+
initialProps: first,
25+
});
26+
27+
result.current();
28+
expect(first).toHaveBeenCalledTimes(1);
29+
expect(second).not.toHaveBeenCalled();
30+
31+
rerender(second);
32+
33+
result.current();
34+
expect(first).toHaveBeenCalledTimes(1);
35+
expect(second).toHaveBeenCalledTimes(1);
36+
});
37+
38+
it("forwards arguments and returns the handler's value", () => {
39+
const handler = vi.fn((a: number, b: number) => a + b);
40+
41+
const { result } = renderHook(() => useCurrentCallback(handler));
42+
43+
const sum = result.current(2, 3);
44+
45+
expect(handler).toHaveBeenCalledWith(2, 3);
46+
expect(sum).toBe(5);
47+
});
48+
49+
it("the wrapper captured before a re-render still calls the latest handler", () => {
50+
const first = vi.fn(() => "first");
51+
const second = vi.fn(() => "second");
52+
53+
const { result, rerender } = renderHook((handler: () => string) => useCurrentCallback(handler), {
54+
initialProps: first,
55+
});
56+
57+
// Simulates a consumer capturing the reference (e.g. inside a useEffect
58+
// closure) before the handler closure has changed.
59+
const captured = result.current;
60+
61+
rerender(second);
62+
63+
expect(captured()).toBe("second");
64+
expect(second).toHaveBeenCalledTimes(1);
65+
expect(first).not.toHaveBeenCalled();
66+
});
67+
});

0 commit comments

Comments
 (0)