From 885a339eb7bac7c8eda5003ed127a211a60a5923 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 29 Oct 2026 11:46:09 +0100 Subject: [PATCH 1/3] fix(Message): show error notification when pinning or unpinning fails --- .../hooks/__tests__/usePinHandler.test.tsx | 94 +++++++++++++++++++ src/components/Message/hooks/usePinHandler.ts | 6 +- 2 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 src/components/Message/hooks/__tests__/usePinHandler.test.tsx diff --git a/src/components/Message/hooks/__tests__/usePinHandler.test.tsx b/src/components/Message/hooks/__tests__/usePinHandler.test.tsx new file mode 100644 index 0000000000..fa8f20693a --- /dev/null +++ b/src/components/Message/hooks/__tests__/usePinHandler.test.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; + +import { usePinHandler } from '../usePinHandler'; + +import { ChannelActionProvider } from '../../../../context/ChannelActionContext'; +import { ChannelStateProvider } from '../../../../context/ChannelStateContext'; +import { ChatProvider } from '../../../../context/ChatContext'; +import { + generateChannel, + generateMessage, + generateUser, + getTestClientWithUser, + mockChannelActionContext, + mockChannelStateContext, + mockChatContext, +} from '../../../../mock-builders'; +import type { LocalMessage, MessageResponse } from 'stream-chat'; + +const alice = generateUser({ name: 'alice' }); +const pinMessage = vi.fn(); +const unpinMessage = vi.fn(); +const updateMessage = vi.fn(); +const mouseEventMock = fromPartial({ + preventDefault: vi.fn(() => {}), +}); + +async function renderUsePinHandlerHook(message: LocalMessage) { + const client = await getTestClientWithUser(alice); + client.pinMessage = pinMessage; + client.unpinMessage = unpinMessage; + const channel = generateChannel(); + + const wrapper = ({ children }: { children?: React.ReactNode }) => ( + + + + {children} + + + + ); + + const { result } = renderHook(() => usePinHandler(message), { wrapper }); + return result.current; +} + +describe('usePinHandler custom hook', () => { + afterEach(vi.clearAllMocks); + + it('should pin the message and update it optimistically', async () => { + const message = generateMessage({ pinned: false }) as MessageResponse & LocalMessage; + const { handlePin } = await renderUsePinHandlerHook(message); + await handlePin(mouseEventMock); + expect(pinMessage).toHaveBeenCalledWith(message); + expect(updateMessage).toHaveBeenCalledTimes(1); + expect(updateMessage).toHaveBeenCalledWith(expect.objectContaining({ pinned: true })); + }); + + it('should roll back and throw when pinning fails', async () => { + const message = generateMessage({ pinned: false }) as MessageResponse & LocalMessage; + pinMessage.mockImplementationOnce(() => Promise.reject(new Error('pin failed'))); + const { handlePin } = await renderUsePinHandlerHook(message); + await expect(handlePin(mouseEventMock)).rejects.toThrow('pin failed'); + expect(updateMessage).toHaveBeenCalledTimes(2); + expect(updateMessage).toHaveBeenLastCalledWith(message); + }); + + it('should unpin the message and update it optimistically', async () => { + const message = generateMessage({ pinned: true }) as MessageResponse & LocalMessage; + const { handlePin } = await renderUsePinHandlerHook(message); + await handlePin(mouseEventMock); + expect(unpinMessage).toHaveBeenCalledWith(message); + expect(updateMessage).toHaveBeenCalledTimes(1); + expect(updateMessage).toHaveBeenCalledWith( + expect.objectContaining({ pinned: false }), + ); + }); + + it('should roll back and throw when unpinning fails', async () => { + const message = generateMessage({ pinned: true }) as MessageResponse & LocalMessage; + unpinMessage.mockImplementationOnce(() => Promise.reject(new Error('unpin failed'))); + const { handlePin } = await renderUsePinHandlerHook(message); + await expect(handlePin(mouseEventMock)).rejects.toThrow('unpin failed'); + expect(updateMessage).toHaveBeenCalledTimes(2); + expect(updateMessage).toHaveBeenLastCalledWith(message); + }); +}); diff --git a/src/components/Message/hooks/usePinHandler.ts b/src/components/Message/hooks/usePinHandler.ts index 6e9094eae2..49fc4decb9 100644 --- a/src/components/Message/hooks/usePinHandler.ts +++ b/src/components/Message/hooks/usePinHandler.ts @@ -29,8 +29,9 @@ export const usePinHandler = (message: LocalMessage) => { updateMessage(optimisticMessage); await client.pinMessage(message); - } catch (e) { + } catch (error) { updateMessage(message); + throw error; } } else { try { @@ -45,8 +46,9 @@ export const usePinHandler = (message: LocalMessage) => { updateMessage(optimisticMessage); await client.unpinMessage(message); - } catch (e) { + } catch (error) { updateMessage(message); + throw error; } } }; From 0ccca791cf78b505e48bf8b5197795a6ca08f135 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 29 Oct 2026 13:56:25 +0100 Subject: [PATCH 2/3] fix(Message): keep concurrent updates when rolling back a failed pin --- .../vite/src/CustomMessageUi/variants.tsx | 11 +- .../hooks/__tests__/usePinHandler.test.tsx | 154 +++++++++++++----- src/components/Message/hooks/usePinHandler.ts | 56 +++++-- src/context/MessageContext.tsx | 2 +- 4 files changed, 160 insertions(+), 63 deletions(-) diff --git a/examples/vite/src/CustomMessageUi/variants.tsx b/examples/vite/src/CustomMessageUi/variants.tsx index e0e8866d79..c2815c5f85 100644 --- a/examples/vite/src/CustomMessageUi/variants.tsx +++ b/examples/vite/src/CustomMessageUi/variants.tsx @@ -246,7 +246,16 @@ const CustomMessageUiActions = () => { return (
-