From 45e8fb62a10b500ce45d468da6655a3ea5075631 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 29 Oct 2026 11:59:13 +0100 Subject: [PATCH 1/5] fix(Message): show error notification when pinning or unpinning fails --- .../hooks/__tests__/usePinHandler.test.tsx | 122 ++++++++++++++++++ src/components/Message/hooks/usePinHandler.ts | 2 + 2 files changed, 124 insertions(+) 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..48bebf4ce9 --- /dev/null +++ b/src/components/Message/hooks/__tests__/usePinHandler.test.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { act, renderHook, type RenderHookResult } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; +import type { Channel as ChannelType, LocalMessage, StreamChat } from 'stream-chat'; + +import { usePinHandler } from '../usePinHandler'; +import { + generateChannel, + generateMessage, + generateUser, + getOrCreateChannelApi, + getTestClientWithUser, + useMockedApis, +} from '../../../../mock-builders'; +import { Channel } from '../../../Channel'; +import { Chat } from '../../../Chat'; + +// `usePinHandler` updates the message optimistically through `messagePaginator.ingestItem`, +// rolls it back when the request fails, and rethrows so callers (the Pin message action) can +// show an error notification instead of a success one. + +let channel: ChannelType; +let client: StreamChat; +const mouseEventMock = fromPartial({ + preventDefault: vi.fn(() => {}), +}); + +async function renderUsePinHandler( + message: LocalMessage, + notifications?: Parameters[1], +) { + const wrapper = ({ children }: React.PropsWithChildren) => ( + + {children} + + ); + let rendered: RenderHookResult, unknown>; + await act(async () => { + rendered = await renderHook(() => usePinHandler(message, notifications), { + wrapper, + }); + }); + + return rendered!.result.current; +} + +describe('usePinHandler custom hook', () => { + beforeAll(async () => { + client = await getTestClientWithUser(generateUser()); + const channelData = generateChannel(); + useMockedApis(client, [getOrCreateChannelApi(channelData)]); + channel = client.channel('messaging', channelData.channel.id); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should pin the message and update it optimistically', async () => { + const pinSpy = vi.spyOn(client, 'pinMessage').mockResolvedValue(fromPartial({})); + const ingestSpy = vi.spyOn(channel.messagePaginator, 'ingestItem'); + const message = generateMessage({ pinned: false }) as LocalMessage; + const { handlePin } = await renderUsePinHandler(message); + await act(async () => { + await handlePin(mouseEventMock); + }); + expect(pinSpy).toHaveBeenCalledWith(message); + expect(ingestSpy).toHaveBeenCalledTimes(1); + expect(ingestSpy).toHaveBeenCalledWith(expect.objectContaining({ pinned: true })); + }); + + it('should roll back and throw when pinning fails', async () => { + vi.spyOn(client, 'pinMessage').mockRejectedValue(new Error('pin failed')); + const ingestSpy = vi.spyOn(channel.messagePaginator, 'ingestItem'); + const message = generateMessage({ pinned: false }) as LocalMessage; + const { handlePin } = await renderUsePinHandler(message); + await act(async () => { + await expect(handlePin(mouseEventMock)).rejects.toThrow('pin failed'); + }); + expect(ingestSpy).toHaveBeenCalledTimes(2); + expect(ingestSpy).toHaveBeenLastCalledWith(message); + }); + + it('should unpin the message and update it optimistically', async () => { + const unpinSpy = vi.spyOn(client, 'unpinMessage').mockResolvedValue(fromPartial({})); + const ingestSpy = vi.spyOn(channel.messagePaginator, 'ingestItem'); + const message = generateMessage({ pinned: true }) as LocalMessage; + const { handlePin } = await renderUsePinHandler(message); + await act(async () => { + await handlePin(mouseEventMock); + }); + expect(unpinSpy).toHaveBeenCalledWith(message); + expect(ingestSpy).toHaveBeenCalledTimes(1); + expect(ingestSpy).toHaveBeenCalledWith(expect.objectContaining({ pinned: false })); + }); + + it('should roll back and throw when unpinning fails', async () => { + vi.spyOn(client, 'unpinMessage').mockRejectedValue(new Error('unpin failed')); + const ingestSpy = vi.spyOn(channel.messagePaginator, 'ingestItem'); + const message = generateMessage({ pinned: true }) as LocalMessage; + const { handlePin } = await renderUsePinHandler(message); + await act(async () => { + await expect(handlePin(mouseEventMock)).rejects.toThrow('unpin failed'); + }); + expect(ingestSpy).toHaveBeenCalledTimes(2); + expect(ingestSpy).toHaveBeenLastCalledWith(message); + }); + + it('should notify and throw when pinning fails with notifications configured', async () => { + vi.spyOn(client, 'pinMessage').mockRejectedValue(new Error('pin failed')); + const notify = vi.fn(); + const message = generateMessage({ pinned: false }) as LocalMessage; + const { handlePin } = await renderUsePinHandler(message, { + getErrorNotification: () => 'custom pin error', + notify, + }); + await act(async () => { + await expect(handlePin(mouseEventMock)).rejects.toThrow('pin failed'); + }); + expect(notify).toHaveBeenCalledWith('custom pin error', 'error'); + }); +}); diff --git a/src/components/Message/hooks/usePinHandler.ts b/src/components/Message/hooks/usePinHandler.ts index ff8bce29e7..f56d417dbd 100644 --- a/src/components/Message/hooks/usePinHandler.ts +++ b/src/components/Message/hooks/usePinHandler.ts @@ -78,6 +78,7 @@ export const usePinHandler = ( 'error', ); messagePaginator.ingestItem(message); + throw e; } } else { try { @@ -103,6 +104,7 @@ export const usePinHandler = ( 'error', ); messagePaginator.ingestItem(message); + throw e; } } }; From 72e7798825c5ef357c615a60ec36bf4397a265c1 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 29 Oct 2026 12:10:21 +0100 Subject: [PATCH 2/5] test(Message): assert paginator state in usePinHandler tests --- .../hooks/__tests__/usePinHandler.test.tsx | 67 +++++++++++++------ 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/src/components/Message/hooks/__tests__/usePinHandler.test.tsx b/src/components/Message/hooks/__tests__/usePinHandler.test.tsx index 48bebf4ce9..f31a21620f 100644 --- a/src/components/Message/hooks/__tests__/usePinHandler.test.tsx +++ b/src/components/Message/hooks/__tests__/usePinHandler.test.tsx @@ -25,6 +25,11 @@ const mouseEventMock = fromPartial({ preventDefault: vi.fn(() => {}), }); +// Reads the message back from the channel's paginator, so assertions see the stored state +// rather than only the calls made to it. +const getHeldMessage = (id: string) => + channel.messagePaginator.items?.find((item) => item.id === id); + async function renderUsePinHandler( message: LocalMessage, notifications?: Parameters[1], @@ -58,58 +63,78 @@ describe('usePinHandler custom hook', () => { it('should pin the message and update it optimistically', async () => { const pinSpy = vi.spyOn(client, 'pinMessage').mockResolvedValue(fromPartial({})); - const ingestSpy = vi.spyOn(channel.messagePaginator, 'ingestItem'); - const message = generateMessage({ pinned: false }) as LocalMessage; + const message = generateMessage({ cid: channel.cid, pinned: false }) as LocalMessage; const { handlePin } = await renderUsePinHandler(message); await act(async () => { await handlePin(mouseEventMock); }); expect(pinSpy).toHaveBeenCalledWith(message); - expect(ingestSpy).toHaveBeenCalledTimes(1); - expect(ingestSpy).toHaveBeenCalledWith(expect.objectContaining({ pinned: true })); + expect(getHeldMessage(message.id)).toEqual(expect.objectContaining({ pinned: true })); }); it('should roll back and throw when pinning fails', async () => { - vi.spyOn(client, 'pinMessage').mockRejectedValue(new Error('pin failed')); - const ingestSpy = vi.spyOn(channel.messagePaginator, 'ingestItem'); - const message = generateMessage({ pinned: false }) as LocalMessage; + let rejectRequest: (error: Error) => void = () => {}; + vi.spyOn(client, 'pinMessage').mockReturnValue( + new Promise((_, reject) => { + rejectRequest = reject; + }), + ); + const message = generateMessage({ cid: channel.cid, pinned: false }) as LocalMessage; const { handlePin } = await renderUsePinHandler(message); + let pending: ReturnType; + act(() => { + pending = handlePin(mouseEventMock); + }); + expect(getHeldMessage(message.id)).toEqual(expect.objectContaining({ pinned: true })); await act(async () => { - await expect(handlePin(mouseEventMock)).rejects.toThrow('pin failed'); + rejectRequest(new Error('pin failed')); + await expect(pending).rejects.toThrow('pin failed'); }); - expect(ingestSpy).toHaveBeenCalledTimes(2); - expect(ingestSpy).toHaveBeenLastCalledWith(message); + expect(getHeldMessage(message.id)).toEqual( + expect.objectContaining({ pinned: false }), + ); }); it('should unpin the message and update it optimistically', async () => { const unpinSpy = vi.spyOn(client, 'unpinMessage').mockResolvedValue(fromPartial({})); - const ingestSpy = vi.spyOn(channel.messagePaginator, 'ingestItem'); - const message = generateMessage({ pinned: true }) as LocalMessage; + const message = generateMessage({ cid: channel.cid, pinned: true }) as LocalMessage; const { handlePin } = await renderUsePinHandler(message); await act(async () => { await handlePin(mouseEventMock); }); expect(unpinSpy).toHaveBeenCalledWith(message); - expect(ingestSpy).toHaveBeenCalledTimes(1); - expect(ingestSpy).toHaveBeenCalledWith(expect.objectContaining({ pinned: false })); + expect(getHeldMessage(message.id)).toEqual( + expect.objectContaining({ pinned: false }), + ); }); it('should roll back and throw when unpinning fails', async () => { - vi.spyOn(client, 'unpinMessage').mockRejectedValue(new Error('unpin failed')); - const ingestSpy = vi.spyOn(channel.messagePaginator, 'ingestItem'); - const message = generateMessage({ pinned: true }) as LocalMessage; + let rejectRequest: (error: Error) => void = () => {}; + vi.spyOn(client, 'unpinMessage').mockReturnValue( + new Promise((_, reject) => { + rejectRequest = reject; + }), + ); + const message = generateMessage({ cid: channel.cid, pinned: true }) as LocalMessage; const { handlePin } = await renderUsePinHandler(message); + let pending: ReturnType; + act(() => { + pending = handlePin(mouseEventMock); + }); + expect(getHeldMessage(message.id)).toEqual( + expect.objectContaining({ pinned: false }), + ); await act(async () => { - await expect(handlePin(mouseEventMock)).rejects.toThrow('unpin failed'); + rejectRequest(new Error('unpin failed')); + await expect(pending).rejects.toThrow('unpin failed'); }); - expect(ingestSpy).toHaveBeenCalledTimes(2); - expect(ingestSpy).toHaveBeenLastCalledWith(message); + expect(getHeldMessage(message.id)).toEqual(expect.objectContaining({ pinned: true })); }); it('should notify and throw when pinning fails with notifications configured', async () => { vi.spyOn(client, 'pinMessage').mockRejectedValue(new Error('pin failed')); const notify = vi.fn(); - const message = generateMessage({ pinned: false }) as LocalMessage; + const message = generateMessage({ cid: channel.cid, pinned: false }) as LocalMessage; const { handlePin } = await renderUsePinHandler(message, { getErrorNotification: () => 'custom pin error', notify, From dd0f13f632296260d006d327447c99b17b1cd14d Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 29 Oct 2026 12:19:59 +0100 Subject: [PATCH 3/5] fix(Message): keep concurrent updates when rolling back a failed pin --- .../vite/src/CustomMessageUi/variants.tsx | 11 +- .../hooks/__tests__/usePinHandler.test.tsx | 166 ++++++++++++------ src/components/Message/hooks/usePinHandler.ts | 57 ++++-- src/context/MessageContext.tsx | 1 + 4 files changed, 160 insertions(+), 75 deletions(-) diff --git a/examples/vite/src/CustomMessageUi/variants.tsx b/examples/vite/src/CustomMessageUi/variants.tsx index 64b9bbf898..bfc01324d6 100644 --- a/examples/vite/src/CustomMessageUi/variants.tsx +++ b/examples/vite/src/CustomMessageUi/variants.tsx @@ -247,7 +247,16 @@ const CustomMessageUiActions = () => { return (
- -