What
The deadlock-resolution effect in src/hooks/useRoom.js (lines 491-504) uses a plain Firestore updateDoc call to resolve vote consensus when users leave mid-vote, instead of a Firestore runTransaction. This races with the castVote function (lines 360-408) which correctly uses runTransaction, leading to non-deterministic vote state corruption.
Cause
The castVote function uses a Firestore transaction that atomically reads the room document, modifies the full activeVote map, and writes it back:
// Lines 360-408 — castVote uses transaction (correct)
await runTransaction(db, async (transaction) => {
const roomDoc = await transaction.get(roomRef);
const data = roomDoc.data();
const activeVote = { ...data.activeVote };
// ... modify approvals/rejections/status
transaction.update(roomRef, { activeVote }); // writes entire map atomically
});
But the deadlock-resolution effect (lines 491-504) uses a plain updateDoc with a field-path write:
// Lines 491-504 — deadlock fix uses plain updateDoc (races with transaction)
useEffect(() => {
if (vote.status === 'voting' && vote.initiatorUid === user?.uid) {
if (vote.approvals?.length > activeCount / 2) {
updateDoc(doc(db, 'rooms', roomId), { 'activeVote.status': 'approved' })
.catch(console.error); // partial field update — no transaction!
}
}
}, [roomId, roomData?.activeVote, roomData?.activeUsers, user?.uid]);
Race scenario:
- User A's approval causes
castVote transaction to begin (reads snapshot where approvals=2, activeCount=2).
- User B leaves the room while transaction is in-flight.
- The effect fires, sees
approvals(2) > activeCount(1)/2, calls updateDoc setting status to approved.
- The effect's
updateDoc commits.
- The transaction from step 1 commits, writing the entire
activeVote map based on its stale snapshot, potentially reverting status to what it read at step 1.
Additionally, mixing updateDoc (partial map write) with transaction.update (full map write) creates inconsistent writes: one overwrites just status, the other overwrites the entire activeVote including approvals, rejections, and status.
Fix
Replace the updateDoc call with a Firestore transaction that double-checks the vote is still in voting state before advancing:
useEffect(() => {
if (!roomId || !roomData?.activeVote) return;
const vote = roomData.activeVote;
const activeCount = roomData.activeUsers?.length || 1;
if (vote.status === 'voting' && vote.initiatorUid === user?.uid) {
if (vote.approvals?.length > activeCount / 2) {
- updateDoc(doc(db, 'rooms', roomId), { 'activeVote.status': 'approved' }).catch(
- console.error
- );
+ const roomRef = doc(db, 'rooms', roomId);
+ runTransaction(db, async (transaction) => {
+ const snap = await transaction.get(roomRef);
+ if (!snap.exists()) return;
+ const current = snap.data().activeVote;
+ if (current && current.status === 'voting' && current.initiatorUid === user?.uid) {
+ const freshActiveCount = (snap.data().activeUsers || []).length;
+ if ((current.approvals || []).length > freshActiveCount / 2) {
+ current.status = 'approved';
+ transaction.update(roomRef, { activeVote: current });
+ }
+ }
+ }).catch(console.error);
}
}
}, [roomId, roomData?.activeVote, roomData?.activeUsers, user?.uid]);
Additional Context
This is a subtle distributed-consensus race condition that only manifests in multi-user collaborative scenarios with specific timing (a user leaves while a vote is being counted). It requires understanding Firestore's transaction model, the difference between partial field-path writes and full-document writes, and the asynchronous interleaving of React effects with Firestore operations. The castVote function (lines 360-408) already demonstrates the correct transaction pattern — the deadlock-resolution effect simply needs to use the same approach.
What
The deadlock-resolution effect in
src/hooks/useRoom.js(lines 491-504) uses a plain FirestoreupdateDoccall to resolve vote consensus when users leave mid-vote, instead of a FirestorerunTransaction. This races with thecastVotefunction (lines 360-408) which correctly usesrunTransaction, leading to non-deterministic vote state corruption.Cause
The
castVotefunction uses a Firestore transaction that atomically reads the room document, modifies the fullactiveVotemap, and writes it back:But the deadlock-resolution effect (lines 491-504) uses a plain
updateDocwith a field-path write:Race scenario:
castVotetransaction to begin (reads snapshot where approvals=2, activeCount=2).approvals(2) > activeCount(1)/2, callsupdateDocsetting status toapproved.updateDoccommits.activeVotemap based on its stale snapshot, potentially revertingstatusto what it read at step 1.Additionally, mixing
updateDoc(partial map write) withtransaction.update(full map write) creates inconsistent writes: one overwrites juststatus, the other overwrites the entireactiveVoteincludingapprovals,rejections, andstatus.Fix
Replace the
updateDoccall with a Firestore transaction that double-checks the vote is still invotingstate before advancing:useEffect(() => { if (!roomId || !roomData?.activeVote) return; const vote = roomData.activeVote; const activeCount = roomData.activeUsers?.length || 1; if (vote.status === 'voting' && vote.initiatorUid === user?.uid) { if (vote.approvals?.length > activeCount / 2) { - updateDoc(doc(db, 'rooms', roomId), { 'activeVote.status': 'approved' }).catch( - console.error - ); + const roomRef = doc(db, 'rooms', roomId); + runTransaction(db, async (transaction) => { + const snap = await transaction.get(roomRef); + if (!snap.exists()) return; + const current = snap.data().activeVote; + if (current && current.status === 'voting' && current.initiatorUid === user?.uid) { + const freshActiveCount = (snap.data().activeUsers || []).length; + if ((current.approvals || []).length > freshActiveCount / 2) { + current.status = 'approved'; + transaction.update(roomRef, { activeVote: current }); + } + } + }).catch(console.error); } } }, [roomId, roomData?.activeVote, roomData?.activeUsers, user?.uid]);Additional Context
This is a subtle distributed-consensus race condition that only manifests in multi-user collaborative scenarios with specific timing (a user leaves while a vote is being counted). It requires understanding Firestore's transaction model, the difference between partial field-path writes and full-document writes, and the asynchronous interleaving of React effects with Firestore operations. The
castVotefunction (lines 360-408) already demonstrates the correct transaction pattern — the deadlock-resolution effect simply needs to use the same approach.