Skip to content

Commit 82336fe

Browse files
committed
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
1 parent e4ec2dc commit 82336fe

6 files changed

Lines changed: 130 additions & 26 deletions

File tree

‎examples/tutorial/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"emoji-mart": "^5.6.0",
1717
"react": "^19.2.6",
1818
"react-dom": "^19.2.6",
19-
"stream-chat": "^9.52.0",
19+
"stream-chat": "^9.54.0",
2020
"stream-chat-react": "workspace:^"
2121
},
2222
"devDependencies": {

‎examples/vite/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"modern-normalize": "^3.0.1",
1717
"react": "^19.2.6",
1818
"react-dom": "^19.2.6",
19-
"stream-chat": "^9.52.0",
19+
"stream-chat": "^9.54.0",
2020
"stream-chat-react": "workspace:^"
2121
},
2222
"devDependencies": {

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@
114114
"modern-normalize": "^3.0.1",
115115
"react": "^19.0.0 || ^18.0.0 || ^17.0.0",
116116
"react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0",
117-
"stream-chat": "^9.52.0"
117+
"stream-chat": "^9.54.0"
118118
},
119119
"peerDependenciesMeta": {
120120
"@breezystack/lamejs": {
@@ -179,7 +179,7 @@
179179
"react-dom": "^19.2.6",
180180
"sass": "^1.100.0",
181181
"semantic-release": "^25.0.3",
182-
"stream-chat": "^9.52.0",
182+
"stream-chat": "^9.54.0",
183183
"typescript": "^6.0.3",
184184
"typescript-eslint": "^8.59.4",
185185
"vite": "^8.1.3",

‎src/components/Poll/PollOptionSelector.tsx‎

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import clsx from 'clsx';
2-
import debounce from 'lodash.debounce';
3-
import React, { useMemo } from 'react';
2+
import React, { useCallback, useMemo } from 'react';
43
import type { PollOption, PollState, PollVote, VotingVisibility } from 'stream-chat';
54
import { isVoteAnswer } from 'stream-chat';
65
import { AvatarStack as DefaultAvatarStack } from '../Avatar';
@@ -85,17 +84,13 @@ export const PollOptionSelector = ({
8584
? vote_counts_by_option[maxVotedOptionIds[0]]
8685
: 0;
8786

88-
const toggleVote = useMemo(
89-
() =>
90-
debounce(() => {
91-
if (!canCastVote) return;
92-
const haveVotedForTheOption = !!ownVotesByOptionId[option.id];
93-
return haveVotedForTheOption
94-
? poll.removeVote(ownVotesByOptionId[option.id].id, message.id)
95-
: poll.castVote(option.id, message.id);
96-
}, 100),
97-
[canCastVote, message.id, option.id, ownVotesByOptionId, poll],
98-
);
87+
const toggleVote = useCallback(() => {
88+
if (!canCastVote) return;
89+
const haveVotedForTheOption = !!ownVotesByOptionId[option.id];
90+
return haveVotedForTheOption
91+
? poll.removeVote(ownVotesByOptionId[option.id].id, message.id)
92+
: poll.castVote(option.id, message.id);
93+
}, [canCastVote, message.id, option.id, ownVotesByOptionId, poll]);
9994

10095
const avatarDisplayInfo = useMemo(
10196
() =>
@@ -114,12 +109,22 @@ export const PollOptionSelector = ({
114109
'str-chat__poll-option--votable': canCastVote,
115110
})}
116111
key={`base-poll-option-${option.id}`}
117-
onClick={isInteractive ? toggleVote : undefined}
112+
onClick={
113+
isInteractive
114+
? (event) => {
115+
// a double click would otherwise cast and immediately remove the vote
116+
if (event.detail > 1) return;
117+
toggleVote();
118+
}
119+
: undefined
120+
}
118121
onKeyDown={
119122
isInteractive
120123
? (event) => {
121124
if (event.key !== 'Enter' && event.key !== ' ') return;
122125
event.preventDefault();
126+
// a held key would otherwise toggle the vote on every auto-repeat
127+
if (event.repeat) return;
123128
toggleVote();
124129
}
125130
: undefined

‎src/components/Poll/__tests__/PollOptionList.test.tsx‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,105 @@ describe('PollOptionList', () => {
307307
});
308308
});
309309

310+
it('ignores auto-repeated key presses on a held key', async () => {
311+
const poll = new Poll({
312+
client: fromPartial<StreamChat>({}),
313+
poll: pollWithNoVotes,
314+
});
315+
const castVoteSpy = vi.spyOn(poll, 'castVote').mockResolvedValue(fromPartial({}));
316+
const removeVoteSpy = vi.spyOn(poll, 'removeVote').mockResolvedValue(fromPartial({}));
317+
318+
const { container } = renderComponent({ poll });
319+
const firstOption = container.querySelector(VOTABLE_OPTION_SELECTOR) as HTMLElement;
320+
321+
act(() => {
322+
fireEvent.keyDown(firstOption, { key: 'Enter' });
323+
fireEvent.keyDown(firstOption, { key: 'Enter', repeat: true });
324+
fireEvent.keyDown(firstOption, { key: ' ', repeat: true });
325+
});
326+
327+
await waitFor(() => {
328+
expect(castVoteSpy).toHaveBeenCalledTimes(1);
329+
});
330+
expect(removeVoteSpy).not.toHaveBeenCalled();
331+
});
332+
333+
it('treats a double click as a single vote toggle', async () => {
334+
const poll = new Poll({
335+
client: fromPartial<StreamChat>({}),
336+
poll: pollWithNoVotes,
337+
});
338+
const castVoteSpy = vi.spyOn(poll, 'castVote').mockResolvedValue(fromPartial({}));
339+
const removeVoteSpy = vi.spyOn(poll, 'removeVote').mockResolvedValue(fromPartial({}));
340+
341+
const { container } = renderComponent({ poll });
342+
const firstOption = container.querySelector(VOTABLE_OPTION_SELECTOR) as HTMLElement;
343+
344+
// the browser reports the click count of a multi-click in `detail`
345+
act(() => {
346+
fireEvent.click(firstOption, { detail: 1 });
347+
fireEvent.click(firstOption, { detail: 2 });
348+
fireEvent.click(firstOption, { detail: 3 });
349+
});
350+
351+
await waitFor(() => {
352+
expect(castVoteSpy).toHaveBeenCalledTimes(1);
353+
});
354+
expect(removeVoteSpy).not.toHaveBeenCalled();
355+
});
356+
357+
it('toggles the vote on rapid clicks without waiting for the server', async () => {
358+
const client = await getTestClientWithUser(generateUser());
359+
const poll = new Poll({ client, poll: pollWithNoVotes });
360+
const optionId = pollWithNoVotes.options[0].id;
361+
const messageId = defaultMessageContext.message.id;
362+
const serverVote = {
363+
created_at: new Date().toISOString(),
364+
id: 'server-vote-id',
365+
option_id: optionId,
366+
poll_id: pollWithNoVotes.id,
367+
updated_at: new Date().toISOString(),
368+
user: client.user,
369+
user_id: client.userID,
370+
};
371+
const castPollVoteSpy = vi
372+
.spyOn(client, 'castPollVote')
373+
.mockResolvedValue(fromPartial({ vote: serverVote }));
374+
const removePollVoteSpy = vi
375+
.spyOn(client, 'removePollVote')
376+
.mockResolvedValue(fromPartial({}));
377+
378+
const { container } = renderComponent({ poll });
379+
const firstOption = container.querySelector(VOTABLE_OPTION_SELECTOR) as HTMLElement;
380+
381+
// separate clicks (not a double click), the second one lands before the server
382+
// responded to the first one
383+
act(() => {
384+
fireEvent.click(firstOption, { detail: 1 });
385+
});
386+
expect(firstOption).toHaveAttribute('aria-pressed', 'true');
387+
act(() => {
388+
fireEvent.click(firstOption, { detail: 1 });
389+
});
390+
expect(firstOption).toHaveAttribute('aria-pressed', 'false');
391+
392+
await waitFor(() => {
393+
expect(removePollVoteSpy).toHaveBeenCalledWith(
394+
messageId,
395+
pollWithNoVotes.id,
396+
serverVote.id,
397+
);
398+
});
399+
expect(castPollVoteSpy).toHaveBeenCalledTimes(1);
400+
expect(castPollVoteSpy).toHaveBeenCalledWith(messageId, pollWithNoVotes.id, {
401+
option_id: optionId,
402+
});
403+
expect(removePollVoteSpy).toHaveBeenCalledTimes(1);
404+
expect(castPollVoteSpy.mock.invocationCallOrder[0]).toBeLessThan(
405+
removePollVoteSpy.mock.invocationCallOrder[0],
406+
);
407+
});
408+
310409
it('passes axe on the default poll option list', async () => {
311410
const poll = new Poll({ client: fromPartial<StreamChat>({}), poll: generatePoll() });
312411
const { container } = renderComponent({ poll });

‎yarn.lock‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2069,7 +2069,7 @@ __metadata:
20692069
emoji-mart: "npm:^5.6.0"
20702070
react: "npm:^19.2.6"
20712071
react-dom: "npm:^19.2.6"
2072-
stream-chat: "npm:^9.52.0"
2072+
stream-chat: "npm:^9.54.0"
20732073
stream-chat-react: "workspace:^"
20742074
typescript: "npm:^6.0.3"
20752075
vite: "npm:^8.1.3"
@@ -2095,7 +2095,7 @@ __metadata:
20952095
react: "npm:^19.2.6"
20962096
react-dom: "npm:^19.2.6"
20972097
sass: "npm:^1.100.0"
2098-
stream-chat: "npm:^9.52.0"
2098+
stream-chat: "npm:^9.54.0"
20992099
stream-chat-react: "workspace:^"
21002100
typescript: "npm:^6.0.3"
21012101
vite: "npm:^8.1.3"
@@ -10104,7 +10104,7 @@ __metadata:
1010410104
remark-parse: "npm:^11.0.0"
1010510105
sass: "npm:^1.100.0"
1010610106
semantic-release: "npm:^25.0.3"
10107-
stream-chat: "npm:^9.52.0"
10107+
stream-chat: "npm:^9.54.0"
1010810108
typescript: "npm:^6.0.3"
1010910109
typescript-eslint: "npm:^8.59.4"
1011010110
unified: "npm:^11.0.5"
@@ -10121,7 +10121,7 @@ __metadata:
1012110121
modern-normalize: ^3.0.1
1012210122
react: ^19.0.0 || ^18.0.0 || ^17.0.0
1012310123
react-dom: ^19.0.0 || ^18.0.0 || ^17.0.0
10124-
stream-chat: ^9.52.0
10124+
stream-chat: ^9.54.0
1012510125
dependenciesMeta:
1012610126
"@parcel/watcher":
1012710127
built: true
@@ -10145,9 +10145,9 @@ __metadata:
1014510145
languageName: unknown
1014610146
linkType: soft
1014710147

10148-
"stream-chat@npm:^9.52.0":
10149-
version: 9.52.0
10150-
resolution: "stream-chat@npm:9.52.0"
10148+
"stream-chat@npm:^9.54.0":
10149+
version: 9.54.0
10150+
resolution: "stream-chat@npm:9.54.0"
1015110151
dependencies:
1015210152
"@types/jsonwebtoken": "npm:^9.0.8"
1015310153
"@types/ws": "npm:^8.18.1"
@@ -10163,7 +10163,7 @@ __metadata:
1016310163
built: true
1016410164
husky:
1016510165
built: true
10166-
checksum: 10c0/1ff1a595a17f54e244e1d0803e2821887092b609f8ab56377a006789cdaaed1307ed09cb9a64aecf8f863aefee6050a3b23bd9eec2b14bbbe661a26410d2c8c0
10166+
checksum: 10c0/f8c02372ffa672e759970223811667b8263bbded048a85adbdd6c3ed60a16f72c882d9700538b94a3148a8e4bfa259eafae5effdc0d8e7ffd2525e61f27a190e
1016710167
languageName: node
1016810168
linkType: hard
1016910169

0 commit comments

Comments
 (0)