From 82336fe23742a742ae3f9dfc19c668ed88976be7 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 5 Oct 2026 11:55:19 +0200 Subject: [PATCH 1/2] fix(Poll): vote without debounce, relying on stream-chat optimistic votes Remove the 100ms debounce around casting/removing a poll vote. It delayed every vote, merged a quick vote-then-undo into a single toggle, and was never cancelled. stream-chat 9.54.0 applies own votes optimistically and sends vote requests in order, which covers the race the debounce guarded against, so the stream-chat minimum version moves to ^9.54.0. Ignore auto-repeated keydown and the extra clicks of a multi-click, so a held key or a double click toggles the vote once. Refs REACT-1207 --- examples/tutorial/package.json | 2 +- examples/vite/package.json | 2 +- package.json | 4 +- src/components/Poll/PollOptionSelector.tsx | 33 ++++--- .../Poll/__tests__/PollOptionList.test.tsx | 99 +++++++++++++++++++ yarn.lock | 16 +-- 6 files changed, 130 insertions(+), 26 deletions(-) diff --git a/examples/tutorial/package.json b/examples/tutorial/package.json index f4d2d920e4..4c54061fb9 100644 --- a/examples/tutorial/package.json +++ b/examples/tutorial/package.json @@ -16,7 +16,7 @@ "emoji-mart": "^5.6.0", "react": "^19.2.6", "react-dom": "^19.2.6", - "stream-chat": "^9.52.0", + "stream-chat": "^9.54.0", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/examples/vite/package.json b/examples/vite/package.json index ff9fe2c6ed..9670c077b2 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -16,7 +16,7 @@ "modern-normalize": "^3.0.1", "react": "^19.2.6", "react-dom": "^19.2.6", - "stream-chat": "^9.52.0", + "stream-chat": "^9.54.0", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/package.json b/package.json index 9301b8fdd0..f11738f07f 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "modern-normalize": "^3.0.1", "react": "^19.0.0 || ^18.0.0 || ^17.0.0", "react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0", - "stream-chat": "^9.52.0" + "stream-chat": "^9.54.0" }, "peerDependenciesMeta": { "@breezystack/lamejs": { @@ -179,7 +179,7 @@ "react-dom": "^19.2.6", "sass": "^1.100.0", "semantic-release": "^25.0.3", - "stream-chat": "^9.52.0", + "stream-chat": "^9.54.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.4", "vite": "^8.1.3", diff --git a/src/components/Poll/PollOptionSelector.tsx b/src/components/Poll/PollOptionSelector.tsx index d7ee9a95ab..4e4e2a7ba0 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 { PollOption, PollState, PollVote, VotingVisibility } from 'stream-chat'; import { isVoteAnswer } from 'stream-chat'; import { AvatarStack as DefaultAvatarStack } from '../Avatar'; @@ -85,17 +84,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( () => @@ -114,12 +109,22 @@ export const PollOptionSelector = ({ 'str-chat__poll-option--votable': canCastVote, })} key={`base-poll-option-${option.id}`} - onClick={isInteractive ? toggleVote : undefined} + onClick={ + isInteractive + ? (event) => { + // a double click would otherwise cast and immediately remove the vote + if (event.detail > 1) return; + toggleVote(); + } + : undefined + } onKeyDown={ isInteractive ? (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 496ce69ee4..bd5d5d5f9b 100644 --- a/src/components/Poll/__tests__/PollOptionList.test.tsx +++ b/src/components/Poll/__tests__/PollOptionList.test.tsx @@ -307,6 +307,105 @@ 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('treats a double click as a single vote toggle', 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; + + // the browser reports the click count of a multi-click in `detail` + act(() => { + fireEvent.click(firstOption, { detail: 1 }); + fireEvent.click(firstOption, { detail: 2 }); + fireEvent.click(firstOption, { detail: 3 }); + }); + + await waitFor(() => { + expect(castVoteSpy).toHaveBeenCalledTimes(1); + }); + expect(removeVoteSpy).not.toHaveBeenCalled(); + }); + + it('toggles the vote on rapid clicks without waiting for the server', async () => { + const client = await getTestClientWithUser(generateUser()); + const poll = new Poll({ client, poll: pollWithNoVotes }); + const optionId = pollWithNoVotes.options[0].id; + const messageId = defaultMessageContext.message.id; + const serverVote = { + created_at: new Date().toISOString(), + id: 'server-vote-id', + option_id: optionId, + poll_id: pollWithNoVotes.id, + updated_at: new Date().toISOString(), + user: client.user, + user_id: client.userID, + }; + const castPollVoteSpy = vi + .spyOn(client, 'castPollVote') + .mockResolvedValue(fromPartial({ vote: serverVote })); + const removePollVoteSpy = vi + .spyOn(client, 'removePollVote') + .mockResolvedValue(fromPartial({})); + + const { container } = renderComponent({ poll }); + const firstOption = container.querySelector(VOTABLE_OPTION_SELECTOR) as HTMLElement; + + // separate clicks (not a double click), the second one lands before the server + // responded to the first one + act(() => { + fireEvent.click(firstOption, { detail: 1 }); + }); + expect(firstOption).toHaveAttribute('aria-pressed', 'true'); + act(() => { + fireEvent.click(firstOption, { detail: 1 }); + }); + expect(firstOption).toHaveAttribute('aria-pressed', 'false'); + + await waitFor(() => { + expect(removePollVoteSpy).toHaveBeenCalledWith( + messageId, + pollWithNoVotes.id, + serverVote.id, + ); + }); + expect(castPollVoteSpy).toHaveBeenCalledTimes(1); + expect(castPollVoteSpy).toHaveBeenCalledWith(messageId, pollWithNoVotes.id, { + option_id: optionId, + }); + expect(removePollVoteSpy).toHaveBeenCalledTimes(1); + expect(castPollVoteSpy.mock.invocationCallOrder[0]).toBeLessThan( + removePollVoteSpy.mock.invocationCallOrder[0], + ); + }); + it('passes axe on the default poll option list', async () => { const poll = new Poll({ client: fromPartial({}), poll: generatePoll() }); const { container } = renderComponent({ poll }); diff --git a/yarn.lock b/yarn.lock index 0306691a65..de0969ac63 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2069,7 +2069,7 @@ __metadata: emoji-mart: "npm:^5.6.0" react: "npm:^19.2.6" react-dom: "npm:^19.2.6" - stream-chat: "npm:^9.52.0" + stream-chat: "npm:^9.54.0" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" vite: "npm:^8.1.3" @@ -2095,7 +2095,7 @@ __metadata: react: "npm:^19.2.6" react-dom: "npm:^19.2.6" sass: "npm:^1.100.0" - stream-chat: "npm:^9.52.0" + stream-chat: "npm:^9.54.0" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" vite: "npm:^8.1.3" @@ -10104,7 +10104,7 @@ __metadata: remark-parse: "npm:^11.0.0" sass: "npm:^1.100.0" semantic-release: "npm:^25.0.3" - stream-chat: "npm:^9.52.0" + stream-chat: "npm:^9.54.0" typescript: "npm:^6.0.3" typescript-eslint: "npm:^8.59.4" unified: "npm:^11.0.5" @@ -10121,7 +10121,7 @@ __metadata: modern-normalize: ^3.0.1 react: ^19.0.0 || ^18.0.0 || ^17.0.0 react-dom: ^19.0.0 || ^18.0.0 || ^17.0.0 - stream-chat: ^9.52.0 + stream-chat: ^9.54.0 dependenciesMeta: "@parcel/watcher": built: true @@ -10145,9 +10145,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^9.52.0": - version: 9.52.0 - resolution: "stream-chat@npm:9.52.0" +"stream-chat@npm:^9.54.0": + version: 9.54.0 + resolution: "stream-chat@npm:9.54.0" dependencies: "@types/jsonwebtoken": "npm:^9.0.8" "@types/ws": "npm:^8.18.1" @@ -10163,7 +10163,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/1ff1a595a17f54e244e1d0803e2821887092b609f8ab56377a006789cdaaed1307ed09cb9a64aecf8f863aefee6050a3b23bd9eec2b14bbbe661a26410d2c8c0 + checksum: 10c0/f8c02372ffa672e759970223811667b8263bbded048a85adbdd6c3ed60a16f72c882d9700538b94a3148a8e4bfa259eafae5effdc0d8e7ffd2525e61f27a190e languageName: node linkType: hard From 6612600a5e224fe993fdbf957716d6be97497d93 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 5 Oct 2026 12:26:01 +0200 Subject: [PATCH 2/2] fix(Poll): toggle the vote on every click Drop the multi-click guard, so every click toggles the vote, including both clicks of a double click, like a native checkbox. Refs REACT-1207 --- src/components/Poll/PollOptionSelector.tsx | 10 +- .../Poll/__tests__/PollOptionList.test.tsx | 101 ++++++++---------- 2 files changed, 45 insertions(+), 66 deletions(-) diff --git a/src/components/Poll/PollOptionSelector.tsx b/src/components/Poll/PollOptionSelector.tsx index 4e4e2a7ba0..0900d19e4a 100644 --- a/src/components/Poll/PollOptionSelector.tsx +++ b/src/components/Poll/PollOptionSelector.tsx @@ -109,15 +109,7 @@ export const PollOptionSelector = ({ 'str-chat__poll-option--votable': canCastVote, })} key={`base-poll-option-${option.id}`} - onClick={ - isInteractive - ? (event) => { - // a double click would otherwise cast and immediately remove the vote - if (event.detail > 1) return; - toggleVote(); - } - : undefined - } + onClick={isInteractive ? toggleVote : undefined} onKeyDown={ isInteractive ? (event) => { diff --git a/src/components/Poll/__tests__/PollOptionList.test.tsx b/src/components/Poll/__tests__/PollOptionList.test.tsx index bd5d5d5f9b..9986c8c90e 100644 --- a/src/components/Poll/__tests__/PollOptionList.test.tsx +++ b/src/components/Poll/__tests__/PollOptionList.test.tsx @@ -330,79 +330,66 @@ describe('PollOptionList', () => { expect(removeVoteSpy).not.toHaveBeenCalled(); }); - it('treats a double click as a single vote toggle', async () => { - const poll = new Poll({ - client: fromPartial({}), - poll: pollWithNoVotes, + // Renders a poll whose votes go through stream-chat's optimistic vote updates, with only the + // vote requests mocked. Each cast gets a new server vote id: server-vote-1, server-vote-2... + const renderWithOptimisticVotes = async () => { + const client = await getTestClientWithUser(generateUser()); + const poll = new Poll({ client, poll: pollWithNoVotes }); + const optionId = pollWithNoVotes.options[0].id; + const requests: string[] = []; + let castCount = 0; + vi.spyOn(client, 'castPollVote').mockImplementation(() => { + const id = `server-vote-${++castCount}`; + requests.push(`cast ${id}`); + return Promise.resolve( + fromPartial({ + vote: { + created_at: new Date().toISOString(), + id, + option_id: optionId, + poll_id: pollWithNoVotes.id, + updated_at: new Date().toISOString(), + user: client.user, + user_id: client.userID, + }, + }), + ); }); - 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; - - // the browser reports the click count of a multi-click in `detail` - act(() => { - fireEvent.click(firstOption, { detail: 1 }); - fireEvent.click(firstOption, { detail: 2 }); - fireEvent.click(firstOption, { detail: 3 }); + vi.spyOn(client, 'removePollVote').mockImplementation((_, __, voteId) => { + requests.push(`remove ${voteId}`); + return Promise.resolve(fromPartial({})); }); - await waitFor(() => { - expect(castVoteSpy).toHaveBeenCalledTimes(1); - }); - expect(removeVoteSpy).not.toHaveBeenCalled(); - }); + const { container } = renderComponent({ poll }); + const option = container.querySelector(VOTABLE_OPTION_SELECTOR) as HTMLElement; + return { client, option, optionId, requests }; + }; it('toggles the vote on rapid clicks without waiting for the server', async () => { - const client = await getTestClientWithUser(generateUser()); - const poll = new Poll({ client, poll: pollWithNoVotes }); - const optionId = pollWithNoVotes.options[0].id; + const { client, option, optionId, requests } = await renderWithOptimisticVotes(); const messageId = defaultMessageContext.message.id; - const serverVote = { - created_at: new Date().toISOString(), - id: 'server-vote-id', - option_id: optionId, - poll_id: pollWithNoVotes.id, - updated_at: new Date().toISOString(), - user: client.user, - user_id: client.userID, - }; - const castPollVoteSpy = vi - .spyOn(client, 'castPollVote') - .mockResolvedValue(fromPartial({ vote: serverVote })); - const removePollVoteSpy = vi - .spyOn(client, 'removePollVote') - .mockResolvedValue(fromPartial({})); - - const { container } = renderComponent({ poll }); - const firstOption = container.querySelector(VOTABLE_OPTION_SELECTOR) as HTMLElement; - // separate clicks (not a double click), the second one lands before the server - // responded to the first one + // the second click lands before the server responded to the first one act(() => { - fireEvent.click(firstOption, { detail: 1 }); + fireEvent.click(option); }); - expect(firstOption).toHaveAttribute('aria-pressed', 'true'); + expect(option).toHaveAttribute('aria-pressed', 'true'); act(() => { - fireEvent.click(firstOption, { detail: 1 }); + fireEvent.click(option); }); - expect(firstOption).toHaveAttribute('aria-pressed', 'false'); + expect(option).toHaveAttribute('aria-pressed', 'false'); + // the removal waits for the cast and removes the vote the server created await waitFor(() => { - expect(removePollVoteSpy).toHaveBeenCalledWith( - messageId, - pollWithNoVotes.id, - serverVote.id, - ); + expect(requests).toEqual(['cast server-vote-1', 'remove server-vote-1']); }); - expect(castPollVoteSpy).toHaveBeenCalledTimes(1); - expect(castPollVoteSpy).toHaveBeenCalledWith(messageId, pollWithNoVotes.id, { + expect(client.castPollVote).toHaveBeenCalledWith(messageId, pollWithNoVotes.id, { option_id: optionId, }); - expect(removePollVoteSpy).toHaveBeenCalledTimes(1); - expect(castPollVoteSpy.mock.invocationCallOrder[0]).toBeLessThan( - removePollVoteSpy.mock.invocationCallOrder[0], + expect(client.removePollVote).toHaveBeenCalledWith( + messageId, + pollWithNoVotes.id, + 'server-vote-1', ); });