@poolse/react 2.14.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,48 @@ All notable changes to `@poolse/react` are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
5
  [semver](https://semver.org).
6
6
 
7
+ ## [2.15.0] — 2026-08-23
8
+
9
+ ### Fixed
10
+
11
+ - **Answering a call then hanging up left the other side in it.**
12
+ `accept()` clears `incoming`, and a callee never had an `outgoing`, so
13
+ `hangUp()` resolved no call id and returned before signalling. The
14
+ call id of the call in progress is now tracked in its own right.
15
+ - **A late `call:cancelled` tore down a live call.** The guard fell
16
+ through whenever `incoming` was null — exactly the state `accept()`
17
+ leaves behind — so a caller's ring timeout firing as the callee
18
+ answered dropped the callee straight out of the call.
19
+ - **Every call signal is now matched to the call it belongs to.**
20
+ `accepted`, `declined`, `busy`, `ended` and `cancelled` are ignored
21
+ unless they name the call this client is actually in. A signal about a
22
+ previous call could previously end the current one.
23
+ - **A room could be left connected with nothing holding it.** If a call
24
+ ended while its media was still connecting, the cleanup ran before
25
+ `join()` resolved, found nothing to release, and the connection
26
+ completed into a room no component held — audio still flowing, no UI,
27
+ and no way to hang up. Both `useCallRoom` and `useVoiceRoom` now
28
+ release a room that was superseded mid-connect.
29
+ - **`markBusy` could reject a call the user was being shown.** The
30
+ decision ran inside a `setPhase` updater; updaters must be pure, and
31
+ React may run them more than once.
32
+
33
+ ## [2.14.1] — 2026-08-23
34
+
35
+ ### Added
36
+
37
+ - **`useCallRoom().refreshDevices`** — re-read the device list on
38
+ demand. Devices are already re-read on connect and on hardware
39
+ changes; this covers the case neither does, which is that a browser
40
+ only reveals device ids and labels once the page holds a matching
41
+ permission, and that moment is not subscribable. Call it when opening
42
+ a picker.
43
+
44
+ ### Fixed
45
+
46
+ - Device lists inherited the `MediaDeviceInfo` spread bug from
47
+ `@poolse/sdk`. See its 2.14.1 entry.
48
+
7
49
  ## [2.14.0] — 2026-08-23
8
50
 
9
51
  ### Added
package/dist/index.cjs CHANGED
@@ -1082,6 +1082,7 @@ function useCallRoom(conversationId, opts) {
1082
1082
  const optsRef = react.useRef(opts);
1083
1083
  optsRef.current = opts;
1084
1084
  const joined = react.useRef(false);
1085
+ const held = react.useRef(null);
1085
1086
  const room = react.useMemo(() => {
1086
1087
  if (!conversationId) return null;
1087
1088
  return poolse.call(conversationId, {
@@ -1121,6 +1122,7 @@ function useCallRoom(conversationId, opts) {
1121
1122
  }
1122
1123
  setStatus(room.getStatus());
1123
1124
  setParticipants(room.getParticipants());
1125
+ held.current = room;
1124
1126
  setAlone(false);
1125
1127
  const offStatus = room.onStatus(setStatus);
1126
1128
  const offRoster = room.onParticipants((next) => {
@@ -1136,6 +1138,7 @@ function useCallRoom(conversationId, opts) {
1136
1138
  offAlone();
1137
1139
  offDevices();
1138
1140
  offError();
1141
+ held.current = null;
1139
1142
  if (joined.current) {
1140
1143
  joined.current = false;
1141
1144
  room.leave();
@@ -1143,14 +1146,19 @@ function useCallRoom(conversationId, opts) {
1143
1146
  };
1144
1147
  }, [room, sync, refreshDevices]);
1145
1148
  const join = react.useCallback(async () => {
1146
- if (!room) return false;
1149
+ const target = room;
1150
+ if (!target) return false;
1147
1151
  setError(null);
1148
1152
  try {
1149
- const connected = await room.join();
1153
+ const connected = await target.join();
1154
+ if (held.current !== target) {
1155
+ if (connected) target.leave();
1156
+ return false;
1157
+ }
1150
1158
  joined.current = connected;
1151
1159
  setSfuAvailable(connected);
1152
- sync(room);
1153
- if (connected) refreshDevices(room);
1160
+ sync(target);
1161
+ if (connected) refreshDevices(target);
1154
1162
  return connected;
1155
1163
  } catch (err) {
1156
1164
  setError(err instanceof Error ? err : new Error(String(err)));
@@ -1192,6 +1200,9 @@ function useCallRoom(conversationId, opts) {
1192
1200
  await room.switchCamera();
1193
1201
  setFacing(room.getFacingMode());
1194
1202
  }, [room]);
1203
+ const refresh = react.useCallback(() => {
1204
+ if (room) refreshDevices(room);
1205
+ }, [room, refreshDevices]);
1195
1206
  const selectDevice = react.useCallback(
1196
1207
  async (kind, deviceId) => {
1197
1208
  if (!room) return;
@@ -1212,6 +1223,7 @@ function useCallRoom(conversationId, opts) {
1212
1223
  alone,
1213
1224
  devices,
1214
1225
  activeDevices,
1226
+ refreshDevices: refresh,
1215
1227
  error,
1216
1228
  sfuAvailable,
1217
1229
  join,
@@ -1253,12 +1265,14 @@ function useVoiceRoom(conversationId, opts = {}) {
1253
1265
  const [error, setError] = react.useState(null);
1254
1266
  const optsRef = react.useRef(opts);
1255
1267
  const joined = react.useRef(false);
1268
+ const held = react.useRef(null);
1256
1269
  const room = react.useMemo(
1257
1270
  () => conversationId ? poolse.realtime.voice(conversationId, optsRef.current) : null,
1258
1271
  [poolse, conversationId]
1259
1272
  );
1260
1273
  react.useEffect(() => {
1261
1274
  if (!room) return;
1275
+ held.current = room;
1262
1276
  setStatus(room.getStatus());
1263
1277
  setParticipants(room.getParticipants());
1264
1278
  setMuted(room.isMuted());
@@ -1269,6 +1283,7 @@ function useVoiceRoom(conversationId, opts = {}) {
1269
1283
  offStatus();
1270
1284
  offRoster();
1271
1285
  offError();
1286
+ held.current = null;
1272
1287
  if (joined.current) {
1273
1288
  joined.current = false;
1274
1289
  room.leave();
@@ -1276,10 +1291,15 @@ function useVoiceRoom(conversationId, opts = {}) {
1276
1291
  };
1277
1292
  }, [room]);
1278
1293
  const join = react.useCallback(async () => {
1279
- if (!room) return;
1294
+ const target = room;
1295
+ if (!target) return;
1280
1296
  setError(null);
1281
1297
  try {
1282
- await room.join();
1298
+ await target.join();
1299
+ if (held.current !== target) {
1300
+ target.leave();
1301
+ return;
1302
+ }
1283
1303
  joined.current = true;
1284
1304
  } catch (err) {
1285
1305
  setError(err instanceof Error ? err : new Error(String(err)));
@@ -1305,10 +1325,22 @@ function useCalls(userId, opts = {}) {
1305
1325
  const ringTimeoutMs = opts.ringTimeoutMs ?? 45e3;
1306
1326
  const poolse = usePoolse();
1307
1327
  const [phase, setPhase] = react.useState("idle");
1328
+ const phaseRef = react.useRef("idle");
1329
+ phaseRef.current = phase;
1308
1330
  const [incoming, setIncoming] = react.useState(null);
1309
1331
  const [outgoing, setOutgoing] = react.useState(null);
1310
1332
  const [activeConversationId, setActiveConversationId] = react.useState(null);
1333
+ const [activeCallId, setActiveCallId] = react.useState(null);
1311
1334
  const [error, setError] = react.useState(null);
1335
+ const incomingRef = react.useRef(null);
1336
+ incomingRef.current = incoming;
1337
+ const outgoingRef = react.useRef(null);
1338
+ outgoingRef.current = outgoing;
1339
+ const activeCallIdRef = react.useRef(null);
1340
+ activeCallIdRef.current = activeCallId;
1341
+ const isCurrent = react.useCallback((callId) => {
1342
+ return callId === activeCallIdRef.current || callId === incomingRef.current?.callId || callId === outgoingRef.current?.callId;
1343
+ }, []);
1312
1344
  const actionsRef = react.useRef({
1313
1345
  cancel: async () => {
1314
1346
  },
@@ -1319,40 +1351,45 @@ function useCalls(userId, opts = {}) {
1319
1351
  react.useEffect(() => {
1320
1352
  if (!calls) return;
1321
1353
  const offIncoming = calls.onIncoming((call2) => {
1322
- setPhase((current) => {
1323
- if (current !== "idle") {
1324
- void calls.markBusy(call2).catch(() => {
1325
- });
1326
- return current;
1327
- }
1328
- setIncoming(call2);
1329
- return "ringing-in";
1330
- });
1354
+ if (phaseRef.current !== "idle") {
1355
+ void calls.markBusy(call2).catch(() => {
1356
+ });
1357
+ return;
1358
+ }
1359
+ setIncoming(call2);
1360
+ setPhase("ringing-in");
1331
1361
  });
1332
1362
  const offAccepted = calls.onAccepted((call2) => {
1363
+ if (outgoingRef.current && call2.callId !== outgoingRef.current.callId) return;
1333
1364
  setPhase("active");
1334
1365
  setActiveConversationId(call2.conversationId);
1366
+ setActiveCallId(call2.callId);
1335
1367
  });
1336
- const offDeclined = calls.onDeclined(() => {
1368
+ const offDeclined = calls.onDeclined((call2) => {
1369
+ if (!isCurrent(call2.callId)) return;
1337
1370
  setPhase("declined");
1338
1371
  setOutgoing(null);
1372
+ setActiveCallId(null);
1339
1373
  });
1340
- const offBusy = calls.onBusy(() => {
1374
+ const offBusy = calls.onBusy((call2) => {
1375
+ if (!isCurrent(call2.callId)) return;
1341
1376
  setPhase("busy");
1342
1377
  setOutgoing(null);
1378
+ setActiveCallId(null);
1343
1379
  });
1344
- const offEnded = calls.onEnded(() => {
1380
+ const offEnded = calls.onEnded((call2) => {
1381
+ if (!isCurrent(call2.callId)) return;
1345
1382
  setPhase("idle");
1346
1383
  setIncoming(null);
1347
1384
  setOutgoing(null);
1348
1385
  setActiveConversationId(null);
1386
+ setActiveCallId(null);
1349
1387
  });
1350
1388
  const offCancelled = calls.onCancelled((call2) => {
1351
- setIncoming((current) => {
1352
- if (current && current.callId !== call2.callId) return current;
1353
- setPhase("idle");
1354
- return null;
1355
- });
1389
+ if (call2.callId !== incomingRef.current?.callId) return;
1390
+ setIncoming(null);
1391
+ setPhase("idle");
1392
+ setActiveCallId(null);
1356
1393
  });
1357
1394
  return () => {
1358
1395
  offIncoming();
@@ -1362,7 +1399,7 @@ function useCalls(userId, opts = {}) {
1362
1399
  offEnded();
1363
1400
  offCancelled();
1364
1401
  };
1365
- }, [calls]);
1402
+ }, [calls, isCurrent]);
1366
1403
  const call = react.useCallback(
1367
1404
  async (conversationId, media2 = "audio") => {
1368
1405
  if (!calls) return;
@@ -1370,6 +1407,7 @@ function useCalls(userId, opts = {}) {
1370
1407
  try {
1371
1408
  const placed = await calls.invite(conversationId, media2);
1372
1409
  setOutgoing(placed);
1410
+ setActiveCallId(placed.callId);
1373
1411
  setPhase("ringing-out");
1374
1412
  } catch (err) {
1375
1413
  setError(err instanceof Error ? err : new Error(String(err)));
@@ -1385,6 +1423,7 @@ function useCalls(userId, opts = {}) {
1385
1423
  try {
1386
1424
  await calls.accept(target);
1387
1425
  setActiveConversationId(target.conversationId);
1426
+ setActiveCallId(target.callId);
1388
1427
  setPhase("active");
1389
1428
  setIncoming(null);
1390
1429
  } catch (err) {
@@ -1403,6 +1442,7 @@ function useCalls(userId, opts = {}) {
1403
1442
  setError(err instanceof Error ? err : new Error(String(err)));
1404
1443
  } finally {
1405
1444
  setIncoming(null);
1445
+ setActiveCallId(null);
1406
1446
  setPhase("idle");
1407
1447
  }
1408
1448
  },
@@ -1416,6 +1456,7 @@ function useCalls(userId, opts = {}) {
1416
1456
  setError(err instanceof Error ? err : new Error(String(err)));
1417
1457
  } finally {
1418
1458
  setOutgoing(null);
1459
+ setActiveCallId(null);
1419
1460
  setPhase("idle");
1420
1461
  }
1421
1462
  }, [calls, outgoing]);
@@ -1433,7 +1474,7 @@ function useCalls(userId, opts = {}) {
1433
1474
  }, [phase, ringTimeoutMs]);
1434
1475
  const hangUp = react.useCallback(async () => {
1435
1476
  const conversationId = activeConversationId ?? incoming?.conversationId ?? outgoing?.conversationId;
1436
- const callId = incoming?.callId ?? outgoing?.callId;
1477
+ const callId = activeCallId ?? incoming?.callId ?? outgoing?.callId;
1437
1478
  try {
1438
1479
  if (!calls || !callId || !conversationId) return;
1439
1480
  if (phase === "ringing-out" && outgoing) {
@@ -1450,13 +1491,15 @@ function useCalls(userId, opts = {}) {
1450
1491
  setIncoming(null);
1451
1492
  setOutgoing(null);
1452
1493
  setActiveConversationId(null);
1494
+ setActiveCallId(null);
1453
1495
  }
1454
- }, [calls, phase, incoming, outgoing, activeConversationId]);
1496
+ }, [calls, phase, incoming, outgoing, activeConversationId, activeCallId]);
1455
1497
  const reset = react.useCallback(() => {
1456
1498
  setPhase("idle");
1457
1499
  setIncoming(null);
1458
1500
  setOutgoing(null);
1459
1501
  setActiveConversationId(null);
1502
+ setActiveCallId(null);
1460
1503
  setError(null);
1461
1504
  }, []);
1462
1505
  const media = incoming?.media ?? outgoing?.media ?? "audio";