From 6193230f1d10b1cae138f2ee97490c0f6283a5bd Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 5 Oct 2026 15:27:01 +0200 Subject: [PATCH] fix(Poll): port the poll vote debounce removal (#3308) to v15 Remove the 100ms debounce around casting/removing a poll vote, and ignore auto-repeated keydown so a held key toggles the vote once. Unlike v14, stream-chat 10 does not apply own votes optimistically yet, so a second click before the server responded still reads the previous vote state. The stream-chat version is unchanged. Refs REACT-1207 --- src/components/Poll/PollOptionSelector.tsx | 23 +++++----- .../Poll/__tests__/PollOptionList.test.tsx | 45 +++++++++++++++++++ 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/src/components/Poll/PollOptionSelector.tsx b/src/components/Poll/PollOptionSelector.tsx index 20d304b05d..eecbce4bba 100644 --- a/src/components/Poll/PollOptionSelector.tsx +++ b/src/components/Poll/PollOptionSelector.tsx @@ -1,6 +1,5 @@ import clsx from 'clsx'; -import debounce from 'lodash.debounce'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import type { PollOptionResponseData, PollState, @@ -91,17 +90,13 @@ export const PollOptionSelector = ({ ? vote_counts_by_option[maxVotedOptionIds[0]] : 0; - const toggleVote = useMemo( - () => - debounce(() => { - if (!canCastVote) return; - const haveVotedForTheOption = !!ownVotesByOptionId[option.id]; - return haveVotedForTheOption - ? poll.removeVote(ownVotesByOptionId[option.id].id, message.id) - : poll.castVote(option.id, message.id); - }, 100), - [canCastVote, message.id, option.id, ownVotesByOptionId, poll], - ); + const toggleVote = useCallback(() => { + if (!canCastVote) return; + const haveVotedForTheOption = !!ownVotesByOptionId[option.id]; + return haveVotedForTheOption + ? poll.removeVote(ownVotesByOptionId[option.id].id, message.id) + : poll.castVote(option.id, message.id); + }, [canCastVote, message.id, option.id, ownVotesByOptionId, poll]); const avatarDisplayInfo = useMemo( () => @@ -126,6 +121,8 @@ export const PollOptionSelector = ({ ? (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); + // a held key would otherwise toggle the vote on every auto-repeat + if (event.repeat) return; toggleVote(); } : undefined diff --git a/src/components/Poll/__tests__/PollOptionList.test.tsx b/src/components/Poll/__tests__/PollOptionList.test.tsx index 7e141d0ea3..2279805abf 100644 --- a/src/components/Poll/__tests__/PollOptionList.test.tsx +++ b/src/components/Poll/__tests__/PollOptionList.test.tsx @@ -326,6 +326,51 @@ describe('PollOptionList', () => { }); }); + it('ignores auto-repeated key presses on a held key', async () => { + const poll = new Poll({ + client: fromPartial({}), + poll: pollWithNoVotes, + }); + const castVoteSpy = vi.spyOn(poll, 'castVote').mockResolvedValue(fromPartial({})); + const removeVoteSpy = vi.spyOn(poll, 'removeVote').mockResolvedValue(fromPartial({})); + + const { container } = renderComponent({ poll }); + const firstOption = container.querySelector(VOTABLE_OPTION_SELECTOR) as HTMLElement; + + act(() => { + fireEvent.keyDown(firstOption, { key: 'Enter' }); + fireEvent.keyDown(firstOption, { key: 'Enter', repeat: true }); + fireEvent.keyDown(firstOption, { key: ' ', repeat: true }); + }); + + await waitFor(() => { + expect(castVoteSpy).toHaveBeenCalledTimes(1); + }); + expect(removeVoteSpy).not.toHaveBeenCalled(); + }); + + it('sends the vote right away on click', () => { + const poll = new Poll({ + client: fromPartial({}), + poll: pollWithNoVotes, + }); + const castVoteSpy = vi.spyOn(poll, 'castVote').mockResolvedValue(fromPartial({})); + + const { container } = renderComponent({ poll }); + const firstOption = container.querySelector(VOTABLE_OPTION_SELECTOR) as HTMLElement; + + act(() => { + fireEvent.click(firstOption); + }); + + // checked synchronously: a debounced or otherwise delayed vote would not be sent yet + expect(castVoteSpy).toHaveBeenCalledTimes(1); + expect(castVoteSpy).toHaveBeenCalledWith( + pollWithNoVotes.options[0].id, + defaultMessageContext.message.id, + ); + }); + it('passes axe on the default poll option list', async () => { const poll = new Poll({ client: fromPartial({}), poll: generatePoll() }); const { container } = renderComponent({ poll });