Skip to content

Commit 81ccd10

Browse files
fix(pollr): confirm a pending vote only from a successful ballot read
A timed-out withdrawal leaves the pending selection empty, and every load resets the voter's choices to an empty placeholder before it reads, so a failed re-read (or a failed poll lookup) matched [] == [] and announced "Vote confirmed" while the old vote could still be on chain. The card now confirms only from a completed, successful ballot read made after the submission went unconfirmed (confirmsPendingVote, with a spec), keeping the pending selection on any failed or in-flight read. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 10a5ffb commit 81ccd10

3 files changed

Lines changed: 65 additions & 11 deletions

File tree

‎components/poll/poll-card.tsx‎

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useCallback, useEffect, useState } from 'react'
3+
import { useCallback, useEffect, useRef, useState } from 'react'
44
import { logger } from '@/lib/logger'
55
import toast from 'react-hot-toast'
66
import { ChartBarIcon } from '@heroicons/react/24/outline'
@@ -12,7 +12,7 @@ import { cn, formatNumber } from '@/lib/utils'
1212
import { categorizeError } from '@/lib/error-utils'
1313
import { pollrPollUrl } from '@/lib/poll-embed'
1414
import { pollrIsV4, pollrIsV5 } from '@/lib/constants'
15-
import { choiceDelta, normalizeChoices, sameChoices } from '@/lib/pollr-rules'
15+
import { choiceDelta, confirmsPendingVote, normalizeChoices, sameChoices, type BallotRead, type PendingVote } from '@/lib/pollr-rules'
1616
import type { Poll, PollTally } from '@/lib/services'
1717
import { pollIsClosed, tallyIsFinal } from '@/lib/services/pollr-vote-service'
1818

@@ -73,7 +73,12 @@ export function PollCard({ pollId, postContent, postAuthorId, className }: PollC
7373
// v5: the selection a submission was asked for when one of its writes went
7474
// unconfirmed. The run stopped there, so later picks were never sent; the
7575
// ballot stays open on this selection until a re-read shows it recorded.
76-
const [pendingWanted, setPendingWanted] = useState<number[] | null>(null)
76+
const [pendingVote, setPendingVote] = useState<PendingVote | null>(null)
77+
// The last completed, successful read of the voter's ballots — null while a
78+
// load is in flight or when it failed. Only this may confirm a pending vote:
79+
// `myVotes` is reset to an empty placeholder before every read.
80+
const [ballotRead, setBallotRead] = useState<BallotRead | null>(null)
81+
const readGeneration = useRef(0)
7782

7883
const userId = user?.identityId ?? null
7984
// v5 ballots stay editable until the poll closes; v3 ballots are permanent.
@@ -92,6 +97,7 @@ export function PollCard({ pollId, postContent, postAuthorId, className }: PollC
9297
// failed lookup can't leave the previous account's (or poll's) answer on
9398
// screen as if it belonged to the one now being loaded.
9499
setMyVotes([])
100+
setBallotRead(null)
95101
setTally(null)
96102
try {
97103
const { pollrPollService, pollrVoteService } = await import('@/lib/services')
@@ -122,6 +128,8 @@ export function PollCard({ pollId, postContent, postAuthorId, className }: PollC
122128
// ballot instead of guessing that the user hasn't voted.
123129
if (votesResult.status === 'fulfilled') {
124130
setMyVotes(votesResult.value)
131+
readGeneration.current += 1
132+
setBallotRead({ choices: votesResult.value, generation: readGeneration.current })
125133
} else {
126134
logger.error('PollCard: failed to load own votes', votesResult.reason)
127135
setVotesUnavailable(true)
@@ -145,7 +153,7 @@ export function PollCard({ pollId, postContent, postAuthorId, className }: PollC
145153
const stopEditing = useCallback(() => {
146154
setSelected([])
147155
setEditing(false)
148-
setPendingWanted(null)
156+
setPendingVote(null)
149157
}, [])
150158

151159
// Reset any pending selection when switching polls or signing in/out.
@@ -157,14 +165,15 @@ export function PollCard({ pollId, postContent, postAuthorId, className }: PollC
157165
setClosedOnChain(false)
158166
}, [pollId])
159167

160-
// An unconfirmed submission is settled once a fresh read shows the voter's
161-
// ballots selecting what was asked for. Until then the ballot stays open on
162-
// it, so "Update vote" sends whatever part has not landed.
168+
// An unconfirmed submission is settled once a successful ballot read, made
169+
// after it went unconfirmed, shows the voter's ballots selecting what was
170+
// asked for. A failed or in-flight read settles nothing. Until then the
171+
// ballot stays open on it, so "Update vote" sends whatever has not landed.
163172
useEffect(() => {
164-
if (loading || !pendingWanted || !sameChoices(myVotes, pendingWanted)) return
173+
if (!confirmsPendingVote(pendingVote, ballotRead)) return
165174
stopEditing()
166175
toast.success('Vote confirmed')
167-
}, [loading, myVotes, pendingWanted, stopEditing])
176+
}, [pendingVote, ballotRead, stopEditing])
168177

169178
const isClosed = closedOnChain || (poll ? pollIsClosed(poll) : false)
170179
const hasVoted = myVotes.length > 0
@@ -212,7 +221,7 @@ export function PollCard({ pollId, postContent, postAuthorId, className }: PollC
212221
// Sent, but not seen yet. Leave the vote and tally as they were rather
213222
// than replace them with a read that likely predates the write.
214223
toast('Your vote was sent but is not confirmed yet.', { icon: '⏳', duration: 6000 })
215-
setPendingWanted(wanted)
224+
setPendingVote({ wanted, afterRead: readGeneration.current })
216225
setSelected(wanted)
217226
setEditing(true)
218227
return
@@ -513,7 +522,7 @@ export function PollCard({ pollId, postContent, postAuthorId, className }: PollC
513522
)}
514523
</div>
515524

516-
{pendingWanted && (
525+
{pendingVote && (
517526
<p className="text-xs text-gray-500 dark:text-gray-400">
518527
Not confirmed yet.{' '}
519528
<button

‎lib/pollr-rules.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
applyChoiceDelta,
66
charCount,
77
choiceDelta,
8+
confirmsPendingVote,
89
isChoiceIndex,
910
normalizeChoices,
1011
pollEndsAt,
@@ -122,6 +123,26 @@ describe('ballot plan, multi choice', () => {
122123
})
123124
})
124125

126+
describe('confirming an unconfirmed vote', () => {
127+
const withdrawal = { wanted: [], afterRead: 3 }
128+
129+
it('needs a successful read made after the submission went unconfirmed', () => {
130+
// No completed read (it failed, or is still in flight): nothing confirms,
131+
// even though an empty withdrawal "matches" an empty placeholder.
132+
expect(confirmsPendingVote(withdrawal, null)).toBe(false)
133+
// The read the submission was planned against predates it.
134+
expect(confirmsPendingVote(withdrawal, { choices: [], generation: 3 })).toBe(false)
135+
expect(confirmsPendingVote(withdrawal, { choices: [], generation: 4 })).toBe(true)
136+
})
137+
138+
it('needs the read to show exactly the wanted selection', () => {
139+
expect(confirmsPendingVote(withdrawal, { choices: [1], generation: 4 })).toBe(false)
140+
expect(confirmsPendingVote({ wanted: [0, 2], afterRead: 0 }, { choices: [0], generation: 1 })).toBe(false)
141+
expect(confirmsPendingVote({ wanted: [0, 2], afterRead: 0 }, { choices: [2, 0], generation: 1 })).toBe(true)
142+
expect(confirmsPendingVote(null, { choices: [], generation: 9 })).toBe(false)
143+
})
144+
})
145+
125146
describe('choices and tallies', () => {
126147
it('reads the recorded choices off ballots, skipping withdrawn ones', () => {
127148
expect(recordedChoices([ballot(2, 2), ballot(0, null), ballot(1, 1)])).toEqual([1, 2])

‎lib/pollr-rules.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,30 @@ export function sameChoices(a: readonly number[], b: readonly number[]): boolean
160160
return left.length === right.length && left.every((choice, index) => choice === right[index])
161161
}
162162

163+
/** A submission whose last write went unconfirmed, and the ballot read it was made after. */
164+
export interface PendingVote {
165+
wanted: number[]
166+
/** The generation of the last successful ballot read when it went unconfirmed. */
167+
afterRead: number
168+
}
169+
170+
/** A completed, successful read of the voter's ballots. */
171+
export interface BallotRead {
172+
choices: number[]
173+
/** Increases with every successful read. */
174+
generation: number
175+
}
176+
177+
/**
178+
* Whether `read` settles `pending`: it is a successful read made AFTER the
179+
* submission went unconfirmed, and it shows exactly the wanted selection. A
180+
* read that failed, or one still in flight, is no read at all — an empty
181+
* placeholder must never pass for a recorded withdrawal.
182+
*/
183+
export function confirmsPendingVote(pending: PendingVote | null, read: BallotRead | null): boolean {
184+
return pending !== null && read !== null && read.generation > pending.afterRead && sameChoices(read.choices, pending.wanted)
185+
}
186+
163187
/** What changed between two selections: the choices `next` adds and the ones it drops. */
164188
export function choiceDelta(previous: readonly number[], next: readonly number[]): { added: number[]; removed: number[] } {
165189
const before = new Set(previous)

0 commit comments

Comments
 (0)