@poolse/react 2.14.1 → 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,32 @@ 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
+
7
33
  ## [2.14.1] — 2026-08-23
8
34
 
9
35
  ### 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)));
@@ -1257,12 +1265,14 @@ function useVoiceRoom(conversationId, opts = {}) {
1257
1265
  const [error, setError] = react.useState(null);
1258
1266
  const optsRef = react.useRef(opts);
1259
1267
  const joined = react.useRef(false);
1268
+ const held = react.useRef(null);
1260
1269
  const room = react.useMemo(
1261
1270
  () => conversationId ? poolse.realtime.voice(conversationId, optsRef.current) : null,
1262
1271
  [poolse, conversationId]
1263
1272
  );
1264
1273
  react.useEffect(() => {
1265
1274
  if (!room) return;
1275
+ held.current = room;
1266
1276
  setStatus(room.getStatus());
1267
1277
  setParticipants(room.getParticipants());
1268
1278
  setMuted(room.isMuted());
@@ -1273,6 +1283,7 @@ function useVoiceRoom(conversationId, opts = {}) {
1273
1283
  offStatus();
1274
1284
  offRoster();
1275
1285
  offError();
1286
+ held.current = null;
1276
1287
  if (joined.current) {
1277
1288
  joined.current = false;
1278
1289
  room.leave();
@@ -1280,10 +1291,15 @@ function useVoiceRoom(conversationId, opts = {}) {
1280
1291
  };
1281
1292
  }, [room]);
1282
1293
  const join = react.useCallback(async () => {
1283
- if (!room) return;
1294
+ const target = room;
1295
+ if (!target) return;
1284
1296
  setError(null);
1285
1297
  try {
1286
- await room.join();
1298
+ await target.join();
1299
+ if (held.current !== target) {
1300
+ target.leave();
1301
+ return;
1302
+ }
1287
1303
  joined.current = true;
1288
1304
  } catch (err) {
1289
1305
  setError(err instanceof Error ? err : new Error(String(err)));
@@ -1309,10 +1325,22 @@ function useCalls(userId, opts = {}) {
1309
1325
  const ringTimeoutMs = opts.ringTimeoutMs ?? 45e3;
1310
1326
  const poolse = usePoolse();
1311
1327
  const [phase, setPhase] = react.useState("idle");
1328
+ const phaseRef = react.useRef("idle");
1329
+ phaseRef.current = phase;
1312
1330
  const [incoming, setIncoming] = react.useState(null);
1313
1331
  const [outgoing, setOutgoing] = react.useState(null);
1314
1332
  const [activeConversationId, setActiveConversationId] = react.useState(null);
1333
+ const [activeCallId, setActiveCallId] = react.useState(null);
1315
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
+ }, []);
1316
1344
  const actionsRef = react.useRef({
1317
1345
  cancel: async () => {
1318
1346
  },
@@ -1323,40 +1351,45 @@ function useCalls(userId, opts = {}) {
1323
1351
  react.useEffect(() => {
1324
1352
  if (!calls) return;
1325
1353
  const offIncoming = calls.onIncoming((call2) => {
1326
- setPhase((current) => {
1327
- if (current !== "idle") {
1328
- void calls.markBusy(call2).catch(() => {
1329
- });
1330
- return current;
1331
- }
1332
- setIncoming(call2);
1333
- return "ringing-in";
1334
- });
1354
+ if (phaseRef.current !== "idle") {
1355
+ void calls.markBusy(call2).catch(() => {
1356
+ });
1357
+ return;
1358
+ }
1359
+ setIncoming(call2);
1360
+ setPhase("ringing-in");
1335
1361
  });
1336
1362
  const offAccepted = calls.onAccepted((call2) => {
1363
+ if (outgoingRef.current && call2.callId !== outgoingRef.current.callId) return;
1337
1364
  setPhase("active");
1338
1365
  setActiveConversationId(call2.conversationId);
1366
+ setActiveCallId(call2.callId);
1339
1367
  });
1340
- const offDeclined = calls.onDeclined(() => {
1368
+ const offDeclined = calls.onDeclined((call2) => {
1369
+ if (!isCurrent(call2.callId)) return;
1341
1370
  setPhase("declined");
1342
1371
  setOutgoing(null);
1372
+ setActiveCallId(null);
1343
1373
  });
1344
- const offBusy = calls.onBusy(() => {
1374
+ const offBusy = calls.onBusy((call2) => {
1375
+ if (!isCurrent(call2.callId)) return;
1345
1376
  setPhase("busy");
1346
1377
  setOutgoing(null);
1378
+ setActiveCallId(null);
1347
1379
  });
1348
- const offEnded = calls.onEnded(() => {
1380
+ const offEnded = calls.onEnded((call2) => {
1381
+ if (!isCurrent(call2.callId)) return;
1349
1382
  setPhase("idle");
1350
1383
  setIncoming(null);
1351
1384
  setOutgoing(null);
1352
1385
  setActiveConversationId(null);
1386
+ setActiveCallId(null);
1353
1387
  });
1354
1388
  const offCancelled = calls.onCancelled((call2) => {
1355
- setIncoming((current) => {
1356
- if (current && current.callId !== call2.callId) return current;
1357
- setPhase("idle");
1358
- return null;
1359
- });
1389
+ if (call2.callId !== incomingRef.current?.callId) return;
1390
+ setIncoming(null);
1391
+ setPhase("idle");
1392
+ setActiveCallId(null);
1360
1393
  });
1361
1394
  return () => {
1362
1395
  offIncoming();
@@ -1366,7 +1399,7 @@ function useCalls(userId, opts = {}) {
1366
1399
  offEnded();
1367
1400
  offCancelled();
1368
1401
  };
1369
- }, [calls]);
1402
+ }, [calls, isCurrent]);
1370
1403
  const call = react.useCallback(
1371
1404
  async (conversationId, media2 = "audio") => {
1372
1405
  if (!calls) return;
@@ -1374,6 +1407,7 @@ function useCalls(userId, opts = {}) {
1374
1407
  try {
1375
1408
  const placed = await calls.invite(conversationId, media2);
1376
1409
  setOutgoing(placed);
1410
+ setActiveCallId(placed.callId);
1377
1411
  setPhase("ringing-out");
1378
1412
  } catch (err) {
1379
1413
  setError(err instanceof Error ? err : new Error(String(err)));
@@ -1389,6 +1423,7 @@ function useCalls(userId, opts = {}) {
1389
1423
  try {
1390
1424
  await calls.accept(target);
1391
1425
  setActiveConversationId(target.conversationId);
1426
+ setActiveCallId(target.callId);
1392
1427
  setPhase("active");
1393
1428
  setIncoming(null);
1394
1429
  } catch (err) {
@@ -1407,6 +1442,7 @@ function useCalls(userId, opts = {}) {
1407
1442
  setError(err instanceof Error ? err : new Error(String(err)));
1408
1443
  } finally {
1409
1444
  setIncoming(null);
1445
+ setActiveCallId(null);
1410
1446
  setPhase("idle");
1411
1447
  }
1412
1448
  },
@@ -1420,6 +1456,7 @@ function useCalls(userId, opts = {}) {
1420
1456
  setError(err instanceof Error ? err : new Error(String(err)));
1421
1457
  } finally {
1422
1458
  setOutgoing(null);
1459
+ setActiveCallId(null);
1423
1460
  setPhase("idle");
1424
1461
  }
1425
1462
  }, [calls, outgoing]);
@@ -1437,7 +1474,7 @@ function useCalls(userId, opts = {}) {
1437
1474
  }, [phase, ringTimeoutMs]);
1438
1475
  const hangUp = react.useCallback(async () => {
1439
1476
  const conversationId = activeConversationId ?? incoming?.conversationId ?? outgoing?.conversationId;
1440
- const callId = incoming?.callId ?? outgoing?.callId;
1477
+ const callId = activeCallId ?? incoming?.callId ?? outgoing?.callId;
1441
1478
  try {
1442
1479
  if (!calls || !callId || !conversationId) return;
1443
1480
  if (phase === "ringing-out" && outgoing) {
@@ -1454,13 +1491,15 @@ function useCalls(userId, opts = {}) {
1454
1491
  setIncoming(null);
1455
1492
  setOutgoing(null);
1456
1493
  setActiveConversationId(null);
1494
+ setActiveCallId(null);
1457
1495
  }
1458
- }, [calls, phase, incoming, outgoing, activeConversationId]);
1496
+ }, [calls, phase, incoming, outgoing, activeConversationId, activeCallId]);
1459
1497
  const reset = react.useCallback(() => {
1460
1498
  setPhase("idle");
1461
1499
  setIncoming(null);
1462
1500
  setOutgoing(null);
1463
1501
  setActiveConversationId(null);
1502
+ setActiveCallId(null);
1464
1503
  setError(null);
1465
1504
  }, []);
1466
1505
  const media = incoming?.media ?? outgoing?.media ?? "audio";